haiku.rag/haiku_rag_slim/haiku/rag/doctor.py
Yiorgis Gozadinos 9baa213b34
Hand storage the database location, not the configuration that placed it
Store, connect_lancedb, gather_database_info and run_doctor take a
location, a path or a URI, and classify it with ConnectionMode.of.
SingleDatabaseSession owns the resolved DatabaseRef and passes its
location down. This removes DatabaseRef.connection(), default_db_path,
the placeholder path for URI-backed databases and the per-database
config copies, so the configuration a client holds is the one the caller
gave it. The chat hands its capabilities the scope it opened along with
the client it lends, and the v0.58.0 migration no longer checks local
free disk for a database behind a URI.
2026-09-03 15:11:37 +03:00

1130 lines
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import asyncio
import json
from collections.abc import Callable, Mapping
from enum import StrEnum
from pathlib import Path
import httpx
import numpy as np
import yaml
from pydantic import BaseModel, Field
from haiku.rag.config import AppConfig
from haiku.rag.config.models import (
DuplicateDetectionConfig,
EmbeddingModelConfig,
ModelConfig,
)
from haiku.rag.store.engine import Store, connect_lancedb
from haiku.rag.store.info import get_database_stats
from haiku.rag.store.schema import REQUIRED_TABLES, index_specs
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",
}
# Providers backed by in-process local models — no endpoint to probe.
_LOCAL_PROVIDERS = {"sentence-transformers", "cross-encoder", "jina-local"}
# Item labels that never yield a standalone chunk: pictures (handled via the
# image path), headings (folded into chunk context, not embedded alone), and
# page furniture. A document whose only items carry these labels is expected to
# have no chunks.
_NON_BODY_LABELS = {
"picture",
"section_header",
"title",
"page_header",
"page_footer",
"caption",
}
# Operators care whether an endpoint answers now, not eventually.
_PROBE_TIMEOUT_S = 2.0
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 _active_models(config: AppConfig) -> list[ModelConfig | EmbeddingModelConfig]:
"""Every model role the config activates.
Picture-description and title models are only included when their feature
is enabled (``processing.pictures == "description"`` / ``auto_title``), so
doctor checks exactly the providers the next ingest will use.
"""
models: list[ModelConfig | EmbeddingModelConfig] = [config.embeddings.model]
for model in (config.reranking.model, config.qa.model, config.analysis.model):
if model is not None:
models.append(model)
proc = config.processing
if proc.pictures == "description":
models.append(proc.conversion_options.picture_description.model)
if proc.auto_title:
models.append(proc.title_model)
return models
def _check_api_keys(config: AppConfig, environ: dict[str, str]) -> CheckResult:
# A custom base_url points at a self-hosted OpenAI-compatible endpoint that
# uses a placeholder key, so the SaaS key is only required when a provider
# is used without one, and without a key in the config. Reachability of
# custom endpoints is the probe's job.
need_key = {
model.provider
for model in _active_models(config)
if not model.base_url
and not model.api_key
and model.provider in _PROVIDER_ENV_VARS
}
missing = [
f"{provider} ({_PROVIDER_ENV_VARS[provider]})"
for provider in sorted(need_key)
if not environ.get(_PROVIDER_ENV_VARS[provider])
]
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.",
)
def _classify_unchunked(
no_chunk_ids: set[str],
labels_by_doc: dict[str, set[str]],
supports_images: bool,
) -> list[CheckResult]:
"""Classify chunk-less documents by what they hold.
A document with body-text items but no chunks is always a problem. A
picture-only document is a problem under a multimodal embedder (its picture
chunks are missing) and an indexing gap under a text-only embedder (which
cannot embed images). A document carrying only headings/furniture (or no
items at all) is expected to have no chunks.
"""
text_docs: list[str] = []
picture_docs: list[str] = []
for doc_id in no_chunk_ids:
labels = labels_by_doc.get(doc_id, set())
if any(label not in _NON_BODY_LABELS for label in labels):
text_docs.append(doc_id)
elif "picture" in labels:
picture_docs.append(doc_id)
results: list[CheckResult] = []
if text_docs:
results.append(
CheckResult(
name="documents_text_no_chunks",
severity=Severity.WARN,
message=f"{len(text_docs)} document(s) have text content but no chunks.",
remediation="haiku-rag rebuild",
details=_sample(sorted(text_docs)),
)
)
if picture_docs and supports_images:
results.append(
CheckResult(
name="documents_pictures_no_chunks",
severity=Severity.WARN,
message=f"{len(picture_docs)} document(s) with pictures have no chunks.",
remediation="haiku-rag rebuild",
details=_sample(sorted(picture_docs)),
)
)
elif picture_docs:
results.append(
CheckResult(
name="documents_images_unsearchable",
severity=Severity.WARN,
message=(
f"{len(picture_docs)} image-only document(s) have no chunks; "
"a text-only embedder cannot index images."
),
remediation=(
"Set embeddings.model.multimodal: true on a vllm, voyageai, or "
"cohere model and rebuild to index images."
),
details=_sample(sorted(picture_docs)),
)
)
if not results:
results.append(
CheckResult(
name="documents_without_chunks",
severity=Severity.OK,
message="Every document with content has chunks.",
)
)
return results
async def _check_fts_coverage(store: Store) -> CheckResult:
"""An FTS index that covers no rows, and a populated table with no FTS
index at all, both make lance serve results unsorted by score with
matching rows dropped. optimize indexes the rows of an index that
exists; it never creates one that is absent."""
from lancedb.index import FTS
uncovered: list[str] = []
missing: list[str] = []
for table_name, table in store._tables().items():
declared = [c for c, cfg in index_specs(table_name) if isinstance(cfg, FTS)]
if not declared:
continue
rows = await table.count_rows()
if not rows:
continue
indices = await table.list_indices()
for column in declared:
index = next(
(i for i in indices if column in i.columns and i.index_type == "FTS"),
None,
)
if index is None:
missing.append(f"{table_name}.{column}: no index over {rows} rows")
continue
stats = await table.index_stats(index.name)
if stats is None or stats.num_indexed_rows == 0:
uncovered.append(f"{table_name}.{column}: 0 of {rows} rows indexed")
if missing or uncovered:
return CheckResult(
name="fts_index_coverage",
severity=Severity.FAIL,
message=(
"Full-text search index does not cover its rows; FTS and "
"hybrid results are unsorted and incomplete."
),
remediation=(
"Run 'haiku-rag rebuild --embed-only' to build the index."
if missing
else "Run 'haiku-rag vacuum' to index the rows."
),
details=missing + uncovered,
)
return CheckResult(
name="fts_index_coverage",
severity=Severity.OK,
message="Full-text search indexes are present and cover rows.",
)
async def _column_values(table, column: str) -> list:
rows = await table.query().select([column]).to_list()
return [row[column] for row in rows]
class _DuplicateFamily(BaseModel):
members: list[str]
keep: str
similarity: dict[str, float]
sizes: dict[str, int]
def _duplicate_families(
doc_ids: list[str],
centroids: np.ndarray,
counts: np.ndarray,
cfg: DuplicateDetectionConfig,
) -> list[_DuplicateFamily]:
"""Cluster documents whose embedding centroids are nearly identical.
``centroids`` holds one summed (unnormalized) centroid per document and
``counts`` its embedded-chunk count. Documents below the small-document
floor are dropped; the rest are normalized and clustered by union-find over
pairwise cosine above ``similarity_threshold``. One family per component,
each carrying every member's highest cosine to another member.
"""
centroids = np.asarray(centroids, dtype=np.float32)
counts = np.asarray(counts)
norms = np.linalg.norm(centroids, axis=1)
eligible = np.nonzero((counts >= cfg.min_chunks) & (norms > 0))[0]
if eligible.size < 2:
return []
unit = centroids[eligible] / norms[eligible][:, None]
ids = [doc_ids[i] for i in eligible]
sizes = {doc_ids[i]: int(counts[i]) for i in eligible}
n = len(ids)
# Pairwise cosine, block-wise to avoid a full D×D matrix at once. Each row
# only compares against higher-indexed documents (upper triangle). Cluster
# with union-find and keep only each document's best similarity to a twin —
# a self-similar corpus forms one clique, so storing every pair would be
# O(D²) objects.
parent = list(range(n))
def find(x: int) -> int:
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
best = np.zeros(n, dtype=np.float32)
linked = False
block = 512
for start in range(0, n, block):
sims = unit[start : start + block] @ unit.T
for row in range(sims.shape[0]):
gi = start + row
cols = (
gi + 1 + np.nonzero(sims[row, gi + 1 :] >= cfg.similarity_threshold)[0]
)
if cols.size == 0:
continue
linked = True
row_best = sims[row, cols]
best[gi] = max(best[gi], float(row_best.max()))
best[cols] = np.maximum(best[cols], row_best)
ri = find(gi)
for gj in cols.tolist():
parent[find(gj)] = ri
if not linked:
return []
components: dict[int, list[int]] = {}
for idx in range(n):
components.setdefault(find(idx), []).append(idx)
families: list[_DuplicateFamily] = []
for indices in components.values():
if len(indices) < 2:
continue
members = sorted(ids[i] for i in indices)
# Largest document (most chunks) is the one to keep; smallest id on a tie.
keep = min(members, key=lambda d: (-sizes[d], d))
families.append(
_DuplicateFamily(
members=members,
keep=keep,
similarity={ids[i]: round(float(best[i]), 3) for i in indices},
sizes={d: sizes[d] for d in members},
)
)
return sorted(families, key=lambda f: f.members)
def _common_path_prefix(labels: list[str]) -> str:
"""Longest shared prefix across labels, trimmed to a path boundary.
Returns "" unless the shared prefix is long enough to be worth factoring out
of every line (deep URI trees are otherwise unreadable).
"""
if len(labels) < 2: # pragma: no cover - families always have >=2 members
return ""
lo, hi = min(labels), max(labels)
end = 0
while end < len(lo) and lo[end] == hi[end]:
end += 1
cut = lo.rfind("/", 0, end)
return lo[: cut + 1] if cut > 16 else ""
def _write_duplicates_out(
path: Path, families: list[_DuplicateFamily], label: Callable[[str], str]
) -> None:
"""One block per group; ``keep_suggested`` marks the document to keep and
``similarity`` is the highest centroid cosine to another group member."""
groups = []
for n, family in enumerate(families, start=1):
groups.append(
{
"group": n,
"keep": family.keep,
"documents": [
{
"document_id": member,
"document": label(member),
"chunks": family.sizes[member],
"similarity": family.similarity[member],
"keep_suggested": member == family.keep,
}
for member in family.members
],
}
)
with open(path, "w", encoding="utf-8") as handle:
yaml.safe_dump({"groups": groups}, handle, sort_keys=False, allow_unicode=True)
def _check_duplicate_documents(
doc_ids: list[str],
centroids: np.ndarray,
counts: np.ndarray,
uri_by_doc: Mapping[str, str | None],
title_by_doc: Mapping[str, str | None],
cfg: DuplicateDetectionConfig,
yaml_path: Path | None = None,
) -> CheckResult:
families = _duplicate_families(doc_ids, centroids, counts, cfg)
def label(doc_id: str) -> str:
return uri_by_doc.get(doc_id) or title_by_doc.get(doc_id) or doc_id
if yaml_path is not None:
_write_duplicates_out(yaml_path, families, label)
if not families:
return CheckResult(
name="duplicate_documents",
severity=Severity.OK,
message="No near-duplicate documents detected.",
)
# The terminal report is a summary: show the first few groups whole and
# point at the YAML export for the rest. One block per shown group — a
# header, each member on its own numbered line, then a compact similarity line.
shown = families[:_SAMPLE_LIMIT]
prefix = _common_path_prefix([label(m) for f in shown for m in f.members])
def short(doc_id: str) -> str:
text = label(doc_id)
return text[len(prefix) :] if prefix and text.startswith(prefix) else text
details: list[str] = []
if prefix:
details.append(f"common path: {prefix}")
for n, family in enumerate(shown, start=1):
number = {member: i for i, member in enumerate(family.members, start=1)}
details.append(
f"group {n}{len(family.members)} docs, keep #{number[family.keep]}:"
)
for member in family.members:
details.append(f" #{number[member]} {short(member)}")
sims = ", ".join(
f"#{number[m]} {family.similarity[m]:.0%}" for m in family.members
)
details.append(f" similarity: {sims}")
if len(families) > len(shown):
details.append(
f"... (+{len(families) - len(shown)} more groups; "
"use --duplicates-out to export all)"
)
total_docs = sum(len(f.members) for f in families)
return CheckResult(
name="duplicate_documents",
severity=Severity.WARN,
message=(
f"{len(families)} group(s) of near-identical documents "
f"(potential duplicates), {total_docs} documents."
),
remediation=(
"Review each group and remove redundant copies; duplication may be intentional."
),
details=details,
)
def _check_document_meta_parity(
doc_ids: set[str], meta_doc_ids: set[str]
) -> CheckResult:
"""documents <-> document_meta must be 1:1."""
orphan_docs = doc_ids - meta_doc_ids
orphan_meta = meta_doc_ids - doc_ids
if not (orphan_docs or orphan_meta):
return CheckResult(
name="document_meta_parity",
severity=Severity.OK,
message="documents and document_meta are consistent.",
)
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))]
return CheckResult(
name="document_meta_parity",
severity=Severity.FAIL,
message="documents and document_meta are out of sync.",
remediation="haiku-rag rebuild",
details=details,
)
def _check_orphaned_chunks(chunk_doc_ids: set[str], doc_ids: set[str]) -> CheckResult:
"""Chunks referencing a document that no longer exists."""
orphans = chunk_doc_ids - doc_ids
return CheckResult(
name="orphaned_chunks",
severity=Severity.FAIL if orphans else Severity.OK,
message=(
"Chunks reference missing documents." if orphans else "No orphaned chunks."
),
remediation="haiku-rag rebuild" if orphans else None,
details=_sample(sorted(orphans)),
)
def _check_orphaned_items(item_doc_ids: set[str], doc_ids: set[str]) -> CheckResult:
"""Document items referencing a document that no longer exists."""
orphans = item_doc_ids - doc_ids
return CheckResult(
name="orphaned_document_items",
severity=Severity.FAIL if orphans else Severity.OK,
message=(
"Document items reference missing documents."
if orphans
else "No orphaned document items."
),
remediation="haiku-rag rebuild" if orphans else None,
details=_sample(sorted(orphans)),
)
def _check_documents_without_items(
doc_ids: set[str], chunk_doc_ids: set[str], item_doc_ids: set[str]
) -> CheckResult:
"""A chunked document must have items; one without them is corrupt. Empty
documents legitimately have neither, so only chunked ones are flagged."""
missing = (doc_ids & chunk_doc_ids) - item_doc_ids
return CheckResult(
name="documents_without_items",
severity=Severity.WARN if missing else Severity.OK,
message=(
f"{len(missing)} chunked document(s) have no document items."
if missing
else "Every chunked document has document items."
),
remediation="haiku-rag rebuild" if missing else None,
details=_sample(sorted(missing)),
)
def _check_dangling_item_refs(
chunk_rows: list[dict], self_refs_by_doc: dict[str, set[str]]
) -> CheckResult:
"""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"])
return 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),
)
def _check_vector_dimension(stored_dim: int | None, actual_dim: int) -> CheckResult:
if stored_dim and stored_dim != actual_dim:
return 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",
)
return CheckResult(
name="vector_dimension",
severity=Severity.OK,
message=f"Chunk vectors are {actual_dim}-dimensional.",
)
def _check_unembedded_chunks(id_column, embedded: "np.ndarray") -> CheckResult:
"""All-zero vectors, reported as a count with a few sampled ids so a large
corpus never materializes every chunk id."""
zero_rows = np.nonzero(~embedded)[0]
zero_count = int(zero_rows.size)
sample = [id_column[int(i)].as_py() for i in zero_rows[:_SAMPLE_LIMIT]]
if zero_count > _SAMPLE_LIMIT:
sample.append(f"... (+{zero_count - _SAMPLE_LIMIT} more)")
return CheckResult(
name="unembedded_chunks",
severity=Severity.WARN if zero_count else Severity.OK,
message=(
f"{zero_count} chunk(s) have an all-zero (unembedded) vector."
if zero_count
else "All chunks are embedded."
),
remediation="haiku-rag rebuild --embed-only" if zero_count else None,
details=sample,
)
def _document_centroids(
document_id_column, vectors: "np.ndarray", embedded: "np.ndarray", dim: int
) -> tuple[list[str], "np.ndarray", "np.ndarray"]:
"""Reduce each document's chunk vectors to one summed centroid.
Dictionary-encode the document ids into integer codes, then sum each
document's embedded rows in a single pass per document — no second full copy
of the vector matrix. Returns (document ids, summed centroids, chunk counts);
the caller normalizes.
"""
encoded = document_id_column.combine_chunks().dictionary_encode()
ids = encoded.dictionary.to_pylist()
codes = encoded.indices.to_numpy(zero_copy_only=False)
centroids = np.zeros((len(ids), dim), dtype=np.float32)
counts = np.zeros(len(ids), dtype=np.int64)
order = np.argsort(codes, kind="stable")
bounds = np.searchsorted(codes, np.arange(len(ids) + 1), sorter=order)
for d in range(len(ids)):
rows = order[bounds[d] : bounds[d + 1]]
rows = rows[embedded[rows]]
counts[d] = rows.size
if rows.size:
centroids[d] = vectors[rows].sum(axis=0)
return ids, centroids, counts
def _check_picture_data(
missing_picture_docs: list[str], content_type_by_doc: dict[str, str]
) -> CheckResult:
"""Pictures from image/PDF sources should carry raster bytes. Pictures that
are external image references in a text document (markdown, HTML) have no
embedded bytes by nature, so a missing raster there is expected."""
real_missing = [
doc_id
for doc_id in missing_picture_docs
if not content_type_by_doc.get(doc_id, "").startswith("text/")
]
return CheckResult(
name="picture_data",
severity=Severity.WARN if real_missing else Severity.OK,
message=(
f"{len(real_missing)} picture item(s) in image/PDF documents "
"have no image data."
if real_missing
else "Pictures that should carry image data have it."
),
remediation="haiku-rag rebuild" if real_missing else None,
details=_sample(sorted(set(real_missing))),
)
def _check_settings_row(total_settings: int, canonical: int) -> CheckResult:
"""Settings must hold exactly one canonical row."""
if total_settings == 0 or canonical != 1:
return 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",
)
return CheckResult(
name="settings_row",
severity=Severity.OK,
message="Settings row is present.",
)
def _check_pending_migrations(stored_version: str) -> CheckResult:
pending = (
get_pending_upgrades(stored_version) if stored_version != "unknown" else []
)
return 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],
)
async def run_db_checks(
store: Store,
config: AppConfig,
stats: dict,
duplicates_out: Path | None = None,
on_progress: Callable[[str], None] | None = None,
) -> list[CheckResult]:
"""Referential and content-integrity checks against an open read-only Store.
Assumes all required tables exist (the caller short-circuits otherwise).
"""
notify = on_progress or (lambda _label: None)
results: list[CheckResult] = []
notify("Reading document records")
doc_ids = set(await _column_values(store.documents_table, "id"))
meta_rows = (
await store.document_meta_table.query()
.select(["id", "metadata", "uri", "title"])
.to_list()
)
meta_doc_ids = {row["id"] for row in meta_rows}
content_type_by_doc = {
row["id"]: json.loads(row.get("metadata") or "{}").get("content_type", "")
for row in meta_rows
}
uri_by_doc = {row["id"]: row.get("uri") for row in meta_rows}
title_by_doc = {row["id"]: row.get("title") for row in meta_rows}
notify("Reading chunks")
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}
notify("Reading document items")
item_rows = (
await store.document_items_table.query()
.select(["document_id", "self_ref", "label"])
.to_list()
)
item_doc_ids = {row["document_id"] for row in item_rows}
self_refs_by_doc: dict[str, set[str]] = {}
labels_by_doc: dict[str, set[str]] = {}
for row in item_rows:
self_refs_by_doc.setdefault(row["document_id"], set()).add(row["self_ref"])
labels_by_doc.setdefault(row["document_id"], set()).add(row["label"])
notify("Checking index coverage")
results.append(await _check_fts_coverage(store))
notify("Checking referential integrity")
results.append(_check_document_meta_parity(doc_ids, meta_doc_ids))
results.append(_check_orphaned_chunks(chunk_doc_ids, doc_ids))
results.append(_check_orphaned_items(item_doc_ids, doc_ids))
notify("Checking document chunking")
results += _classify_unchunked(
doc_ids - chunk_doc_ids, labels_by_doc, store.embedder.supports_images
)
results.append(_check_documents_without_items(doc_ids, chunk_doc_ids, item_doc_ids))
notify("Checking chunk references")
results.append(_check_dangling_item_refs(chunk_rows, self_refs_by_doc))
notify("Scanning chunk vectors")
# Vector dimension, unembedded vectors and duplicate detection share one
# scan of the vector column — the heaviest read on large corpora.
arrow = (
await store.chunks_table.query()
.select(["id", "vector", "document_id"])
.to_arrow()
)
stored = store.stored_settings
stored_dim = stored.get("embeddings", {}).get("model", {}).get("vector_dim")
actual_dim = arrow.schema.field("vector").type.list_size
results.append(_check_vector_dimension(stored_dim, actual_dim))
# Reshape the Arrow fixed-size-list child buffer directly into an (N, dim)
# float32 matrix. Going through to_pylist() would box N*dim Python floats
# (tens of GB and most of the wall-clock on large corpora); the stored
# vectors are already float32, so this keeps the layout and the dtype.
vec_col = arrow.column("vector").combine_chunks()
vectors = vec_col.values.to_numpy(zero_copy_only=False).reshape(-1, actual_dim)
embedded = vectors.any(axis=1) if vectors.size else np.zeros(0, dtype=bool)
results.append(_check_unembedded_chunks(arrow.column("id"), embedded))
notify("Detecting near-duplicate documents")
centroid_doc_ids, centroids, counts = _document_centroids(
arrow.column("document_id"), vectors, embedded, actual_dim
)
# The matrix is the largest object here; drop it before clustering.
del vectors
results.append(
_check_duplicate_documents(
centroid_doc_ids,
centroids,
counts,
uri_by_doc,
title_by_doc,
config.doctor.duplicates,
yaml_path=duplicates_out,
)
)
notify("Checking picture data")
missing_picture_docs = [
row["document_id"]
for row in await store.document_items_table.query()
.select(["document_id"])
.where("label = 'picture' AND picture_data IS NULL")
.to_list()
]
results.append(_check_picture_data(missing_picture_docs, content_type_by_doc))
notify("Checking settings and indexes")
total_settings = await store.settings_table.count_rows()
canonical = len(
await store.settings_table.query().where("id = 'settings'").to_list()
)
results.append(_check_settings_row(total_settings, canonical))
results.append(_check_embedding_drift(stored, config))
results.append(_check_pending_migrations(str(stored.get("version", "unknown"))))
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 >= 100_000:
return CheckResult(
name="vector_index",
severity=Severity.WARN,
message=(
"No vector index on a large collection; "
"similarity search scans every chunk and may be slow."
),
remediation="haiku-rag create-index",
)
return CheckResult(
name="vector_index",
severity=Severity.OK,
message="No vector index; similarity search is exact (brute-force).",
)
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.",
)
def _resolve_endpoint(
provider: str, base_url: str | None, ollama_base: str
) -> tuple[str, str, str] | str | None:
"""Map a model's provider to a probe target.
Returns ``(probe_url, kind, display)``, the literal ``"local"`` for an
in-process model, or ``None`` for a SaaS provider covered by the API-key
check.
"""
if provider == "ollama":
base = (base_url or ollama_base).rstrip("/")
if base.endswith("/v1"):
base = base[:-3].rstrip("/")
return f"{base}/api/tags", "ollama", base
if provider == "vllm":
base = (base_url or "http://localhost:8000/v1").rstrip("/")
if not base.endswith("/v1"):
base = f"{base}/v1"
return f"{base}/models", "openai", base
if provider == "openai" and base_url:
base = base_url.rstrip("/")
return f"{base}/models", "openai", base
if provider in _LOCAL_PROVIDERS:
return "local"
return None
def _provider_targets(
config: AppConfig,
) -> tuple[dict[str, dict], set[str]]:
"""Collect probe targets (keyed by probe URL) and local-only providers."""
targets: dict[str, dict] = {}
local: set[str] = set()
ollama_base = config.providers.ollama.base_url
def add_model(model: ModelConfig | EmbeddingModelConfig) -> None:
resolved = _resolve_endpoint(model.provider, model.base_url, ollama_base)
if resolved is None:
return
if resolved == "local":
local.add(model.provider)
return
probe_url, kind, display = resolved
entry = targets.setdefault(
probe_url,
{"kind": kind, "display": display, "models": set(), "headers": {}},
)
# A secured endpoint answers the probe only with its key. Models sharing
# a probe URL share the endpoint, so the first key configured for it wins.
if model.api_key and not entry["headers"]:
entry["headers"] = {"Authorization": f"Bearer {model.api_key}"}
if model.name:
entry["models"].add(model.name)
proc = config.processing
if proc.converter == "docling-serve" or proc.chunker == "docling-serve":
docling_key = config.providers.docling_serve.api_key
headers = {"X-Api-Key": docling_key} if docling_key else {}
for url in config.providers.docling_serve.base_urls:
base = url.rstrip("/")
targets.setdefault(
f"{base}/health",
{
"kind": "docling-serve",
"display": base,
"models": set(),
"headers": headers,
},
)
for model in _active_models(config):
add_model(model)
return targets, local
def _model_present(expected: str, available: set[str]) -> bool:
if expected in available:
return True
if ":" not in expected:
return any(a.split(":", 1)[0] == expected for a in available)
return False
async def _probe_endpoint(
client: httpx.AsyncClient, url: str, headers: dict[str, str]
) -> tuple[bool, str | None, dict | None]:
try:
response = await client.get(url, headers=headers)
except httpx.HTTPError as exc:
return False, str(exc), None
if not response.is_success:
return False, f"HTTP {response.status_code}", None
try:
return True, None, response.json()
except ValueError:
return True, None, None
def _endpoint_result(
entry: dict, reachable: bool, error: str | None, payload: dict | None
) -> CheckResult:
kind = entry["kind"]
display = entry["display"]
name = f"provider:{display}"
if not reachable:
return CheckResult(
name=name,
severity=Severity.FAIL,
message=f"{kind} at {display} is unreachable.",
remediation="Start the service or fix the configured base_url.",
details=[error] if error else [],
)
if kind == "ollama":
available = {m.get("name", "") for m in (payload or {}).get("models", [])}
missing = [
model
for model in sorted(entry["models"])
if not _model_present(model, available)
]
if missing:
return CheckResult(
name=name,
severity=Severity.WARN,
message=f"ollama at {display} is reachable but missing model(s).",
remediation="ollama pull <model>",
details=missing,
)
return CheckResult(
name=name,
severity=Severity.OK,
message=f"{kind} at {display} is reachable.",
)
async def run_provider_checks(
config: AppConfig, on_progress: Callable[[str], None] | None = None
) -> list[CheckResult]:
"""Probe the external endpoints the current config actually uses."""
targets, local = _provider_targets(config)
results: list[CheckResult] = []
if targets:
if on_progress is not None:
on_progress("Probing provider endpoints")
async with httpx.AsyncClient(timeout=_PROBE_TIMEOUT_S) as client:
probes = await asyncio.gather(
*(
_probe_endpoint(client, url, targets[url]["headers"])
for url in targets
)
)
for url, (reachable, error, payload) in zip(targets, probes):
results.append(_endpoint_result(targets[url], reachable, error, payload))
for provider in sorted(local):
results.append(
CheckResult(
name=f"provider:{provider}",
severity=Severity.OK,
message=f"{provider}: local model, nothing to probe.",
)
)
return results
async def run_doctor(
config: AppConfig,
location: Path | str,
environ: dict[str, str],
duplicates_out: Path | None = None,
on_progress: Callable[[str], None] | None = None,
) -> 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.
"""
notify = on_progress or (lambda _label: None)
notify("Inspecting tables")
db = await connect_lancedb(location, config)
stats = await get_database_stats(db)
results: list[CheckResult] = []
if not any(entry["exists"] for entry in stats.values()):
results.append(
CheckResult(
name="tables_present",
severity=Severity.FAIL,
message="Database is empty.",
remediation="haiku-rag init",
)
)
else:
results.append(_check_tables_present(stats))
missing = [name for name in REQUIRED_TABLES if not stats[name]["exists"]]
if not missing:
async with Store(
location,
config=config,
skip_validation=True,
read_only=True,
skip_migration_check=True,
) as store:
results += await run_db_checks(
store,
config,
stats,
duplicates_out=duplicates_out,
on_progress=on_progress,
)
notify("Checking API keys")
results.append(_check_api_keys(config, environ))
results += await run_provider_checks(config, on_progress=on_progress)
return DoctorReport(results=results)