haiku.rag/haiku_rag_slim/haiku/rag/client/__init__.py
Yiorgis Gozadinos afdef92b5b
Finish the comment pass, and escape document fields everywhere Rich renders
`_rich_print_document` escapes uri, title and metadata, the sibling of
the escaped search-result renderer. The remaining comments and
docstrings that narrated rejected alternatives, consequences or history
now state the current invariant. The Sandbox class docstring names the
held connection close() releases, and wrapped docs paragraphs join to
one line.
2026-08-28 15:34:47 +03:00

1003 lines
38 KiB
Python

import asyncio
import hashlib
import json
import logging
import mimetypes
import tempfile
from collections.abc import AsyncGenerator, Callable, Coroutine, Sequence
from enum import Enum
from functools import cached_property
from itertools import zip_longest
from pathlib import Path
from time import monotonic
from typing import TYPE_CHECKING, Any, overload
from urllib.parse import urlparse
import httpx
from haiku.rag.client.documents import DocumentImport
from haiku.rag.client.scope import DatabaseRef, DatabaseScope
from haiku.rag.client.session import (
FederatedSession,
SingleDatabaseSession,
aclose_quietly,
default_db_path,
)
from haiku.rag.config import AppConfig, get_config
from haiku.rag.converters import get_converter
from haiku.rag.embeddings import get_embedder
from haiku.rag.reranking import get_reranker
from haiku.rag.store.engine import Store
from haiku.rag.store.exceptions import (
AmbiguousDatabaseError,
ConfigMismatchError,
MigrationRequiredError,
ReadOnlyError,
SourceUnavailableError,
UnknownDatabaseError,
)
from haiku.rag.store.models.chunk import Chunk, SearchResult, SearchType
from haiku.rag.store.models.document import Document
from haiku.rag.store.models.document_item import extract_items
from haiku.rag.store.repositories.chunk import ChunkRepository
from haiku.rag.store.repositories.document import DocumentRepository
from haiku.rag.store.repositories.document_item import DocumentItemRepository
from haiku.rag.store.repositories.settings import SettingsRepository
from haiku.rag.utils import escape_sql_string, gather_all
if TYPE_CHECKING:
from docling_core.types.doc.document import DoclingDocument
from PIL import Image as PILImage
from haiku.rag.embeddings import EmbedderWrapper
from haiku.rag.ingester.metadata import MetadataProvider
from haiku.rag.reranking.base import RerankerBase
from haiku.rag.sandbox import AnalysisResult
from haiku.rag.sources.base import Source
from haiku.rag.store.models.citation import Citation
logger = logging.getLogger(__name__)
async def all_found(
clients: "list[HaikuRAG]",
lookup: "Callable[[HaikuRAG], Coroutine[Any, Any, Any]]",
) -> "list[tuple[HaikuRAG, Any]]":
"""Every client for which `lookup` finds something, and what each found.
An id or a URI says nothing about which database holds it, so every one is
asked at once.
"""
found_by_client = await gather_all(*(lookup(client) for client in clients))
return [
(client, found)
for client, found in zip(clients, found_by_client, strict=True)
if found is not None
]
async def first_found(
clients: "list[HaikuRAG]",
lookup: "Callable[[HaikuRAG], Coroutine[Any, Any, Any]]",
) -> "tuple[HaikuRAG, Any] | None":
"""The first of `clients` for which `lookup` finds something, and what it found.
For a lookup that has an answer wherever it is found, as a document read
does. Where holding the same identifier in two databases means something,
ask `all_found` and decide.
"""
found = await all_found(clients, lookup)
return found[0] if found else None
def _spell(embedding: tuple[str | None, str | None, int | None]) -> str:
"""An embedder identity, for an error message."""
provider, name, vector_dim = embedding
return f"{provider}/{name} at {vector_dim} dimensions"
def _without_repeats(names: list[str]) -> list[str]:
"""`names` in order, without repeats: a name selects its database once."""
return list(dict.fromkeys(names))
class RebuildMode(Enum):
"""Mode for rebuilding the database."""
FULL = "full" # Re-convert from source, re-chunk, re-embed
RECHUNK = "rechunk" # Re-chunk from existing content, re-embed
EMBED_ONLY = "embed_only" # Keep chunks, only regenerate embeddings
TITLE_ONLY = "title_only" # Only generate titles for untitled documents
DESCRIPTIONS = "descriptions" # Run the VLM over already-stored picture
# bytes, patch descriptions into the docling blob, then re-chunk + re-embed.
SET_EMBEDDER = "set_embedder" # Adopt the current embedder identity without
# re-embedding, when the vector dimension is unchanged.
class HaikuRAG:
"""High-level haiku-rag client."""
def __init__(
self,
db_path: Path | str | None = None,
config: AppConfig | None = None,
skip_validation: bool = False,
create: bool = False,
read_only: bool = False,
sources: list[str] | None = None,
):
"""Initialize the RAG client with a database path.
Args:
db_path: Path or string path to the database. When omitted, resolves
``lancedb.databases``, then ``lancedb.uri``, then the default
path under ``storage.data_dir``.
config: Configuration to use. Defaults to the current global config.
skip_validation: Whether to skip configuration validation on database load.
create: Whether to create the database if it doesn't exist.
read_only: Whether to open the database in read-only mode.
sources: Names from ``config.lancedb.databases`` this client covers,
None for all of them. Only that setting names databases, so a
name raises when ``lancedb.uri`` placed the database, and is
rejected alongside ``db_path``, which says the same thing
another way. ``[]`` raises too: a client over no database can do
nothing, unlike ``sources=[]`` on a search, which is a selection
of nothing to search.
"""
self._configured = config if config is not None else get_config()
# What the caller configured, kept intact: entering derives a
# single-database configuration from it, and every re-entry derives
# from the configured set.
self._config = self._configured
self._requested_db_path = Path(db_path) if db_path is not None else None
if self._requested_db_path is not None and sources is not None:
raise AmbiguousDatabaseError(
"a path and `sources` both say which databases to open; pass "
"one of them"
)
self._skip_validation = skip_validation
self._create = create
self._read_only = read_only
self._requested_sources = sources
self._clients: dict[str, HaikuRAG] = {}
# The client this one covers a database for, whose reranker it borrows.
self._lender: HaikuRAG | None = None
self._scope: DatabaseScope | None = None
self._session: SingleDatabaseSession | FederatedSession | None = None
self._owns_session = True
self._closed = False
@property
def covers_multiple(self) -> bool:
"""Whether this client reads from more than one database."""
return isinstance(self._session, FederatedSession)
@property
def source_names(self) -> tuple[str, ...]:
"""The configured databases this client covers, in configured order.
A single database contributes its own name, or nothing where the
configuration named none.
"""
if isinstance(self._session, FederatedSession):
return self._session.names
return () if self.source is None else (self.source,)
@property
def source(self) -> str | None:
"""The configured database this client reads, or None while covering a
set or reading a database the configuration did not name."""
if isinstance(self._session, SingleDatabaseSession):
return self._session.source
return None
@property
def location(self) -> "Path | str | None":
"""Where the database this client reads is, or None while covering a set."""
if not isinstance(self._session, SingleDatabaseSession):
return None
return self._session.location
async def reader_for(self, source: str | None) -> "HaikuRAG | None":
"""The client that can read `source` — itself, where it reads one
database.
None only when a client covering a set is given no name. A name this
client does not cover raises `UnknownDatabaseError`, decided by
`clients_covering`: one database answers a wrong name the same way a
set does.
"""
if source is None:
return None if self.covers_multiple else self
(owner,) = await self.clients_covering([source])
return owner
def _single_session(self, operation: str) -> SingleDatabaseSession:
"""The one database this operation works on.
Writing, rebuilding and vacuuming all name a database, so they start
here: the session they get cannot be a set, and the refusal is the same
sentence whichever operation asked.
"""
if isinstance(self._session, SingleDatabaseSession):
return self._session
covered = ", ".join(sorted(self.source_names))
raise AmbiguousDatabaseError(
f"{operation} works on one database, and this client covers "
f"{covered}; select one with clients_for([name])"
)
@property
def store(self) -> Store:
"""The store of the database this client opened.
Absent while covering a set: a store has no unambiguous meaning across
several, and `clients_for` reaches the one holding a given database.
"""
if not isinstance(self._session, SingleDatabaseSession):
raise AttributeError("store")
return self._session.store
@property
def document_repository(self) -> DocumentRepository:
if not isinstance(self._session, SingleDatabaseSession):
raise AttributeError("document_repository")
return self._session.document_repository
@property
def chunk_repository(self) -> ChunkRepository:
if not isinstance(self._session, SingleDatabaseSession):
raise AttributeError("chunk_repository")
return self._session.chunk_repository
@property
def document_item_repository(self) -> DocumentItemRepository:
if not isinstance(self._session, SingleDatabaseSession):
raise AttributeError("document_item_repository")
return self._session.document_item_repository
@property
def is_read_only(self) -> bool:
"""Whether the client is in read-only mode.
The mode the client was opened with, which is the mode every database it
covers is opened with. A client covering a set has no store to ask.
"""
return self._read_only
@cached_property
def embedder(self) -> "EmbedderWrapper":
"""The embedder for the databases this client covers.
An embedder is a function of configuration, not of a database,
and the databases in a selection are required to share one, so a client
covering a set has an unambiguous embedder without opening any of them.
Built on first use and owned by this client, which closes it.
"""
if self.covers_multiple:
return get_embedder(config=self._config)
return self.store.embedder
@property
def reranker(self) -> "RerankerBase | None":
"""The configured reranker, built once and reused across searches.
None when reranking is disabled. A client covering a database for
another borrows that one's, built on the first query to reach any of
them.
"""
if self._lender is not None:
return self._lender.reranker
return self._own_reranker
@cached_property
def _own_reranker(self) -> "RerankerBase | None":
return get_reranker(config=self._config)
def _resolve_scope(self) -> DatabaseScope:
"""The databases this client covers.
Resolved once, here or by whoever handed one in. An explicit `db_path`
says which database to open, so a configured set does not override it.
"""
if self._scope is not None:
return self._scope
scope = DatabaseScope.resolve(
self._configured, database_path=self._requested_db_path
)
if self._requested_sources is not None and self._requested_db_path is None:
scope = scope.select(self._requested_sources)
self._scope = scope
return scope
async def __aenter__(self):
"""Async context manager entry — initializes store and repositories.
A borrowed client reuses its session, which its owner closes. A client
covering several opens their sessions lazily, so `store` and the
repositories stay unset until one database is named.
"""
self._closed = False
if not self._owns_session:
assert self._session is not None
return self
scope = self._resolve_scope()
if scope.covers_multiple:
if self._create:
raise AmbiguousDatabaseError(
"create=True creates one database, and this client covers "
f"{', '.join(sorted(scope.names))}; name the one to create "
"with sources=[name]"
)
self._session = FederatedSession(
scope,
self._configured,
skip_validation=self._skip_validation,
read_only=self._read_only,
)
return self
[ref] = scope.databases
self._config, db_path = ref.connection(self._configured)
self._session = await SingleDatabaseSession(
db_path if db_path is not None else default_db_path(self._config),
self._config,
skip_validation=self._skip_validation,
create=self._create,
read_only=self._read_only,
source=ref.name,
).open()
return self
async def clients_for(self, names: list[str]) -> list["HaikuRAG"]:
"""The clients for these databases, opening any not yet open.
Opening is per query: a set of 25 configured databases is typically
queried a few at a time, and a database the query does not cover stays
closed.
The clients returned borrow their databases from this one and are valid
only while it is open. Closing one, or entering it as a context manager,
leaves the database alone; this client closes them all on teardown.
"""
assert isinstance(self._session, FederatedSession)
names = _without_repeats(names)
sessions = await self._session.sessions_for(names)
return [
self._facade_for(name, session)
for name, session in zip(names, sessions, strict=True)
]
def _facade_for(self, name: str, session: SingleDatabaseSession) -> "HaikuRAG":
"""The cached client borrowing this session, made once and kept."""
facade = self._clients.get(name)
if facade is None:
facade = HaikuRAG._from_session(session, lender=self)
self._clients[name] = facade
return facade
@classmethod
def _covering(
cls,
scope: DatabaseScope,
config: AppConfig | None = None,
*,
read_only: bool = False,
create: bool = False,
skip_validation: bool = False,
) -> "HaikuRAG":
"""A client over databases someone already resolved.
Internal: the public constructor takes a path or names, and resolving
those is its own job. This is for callers that did the resolving.
"""
client = cls(
config=config,
read_only=read_only,
create=create,
skip_validation=skip_validation,
)
client._scope = scope
return client
@classmethod
def _from_session(
cls, session: SingleDatabaseSession, lender: "HaikuRAG | None" = None
) -> "HaikuRAG":
"""A client over a database another session opened and will close.
`lender` is the client that opened it, whose reranker this one borrows.
"""
client = cls(
session.db_path, config=session.config, read_only=session.read_only
)
client._session = session
client._owns_session = False
client._lender = lender
return client
def _require_one_embedder(self, clients: "list[HaikuRAG]") -> None:
"""Fail when two of these databases were written with different embedders.
Only databases searched together have to agree: this is a property of
the selection, not of the set. Drift between a database and the *config*
is a separate, softer matter, which `SettingsRepository` reports on
open.
"""
recorded = [
(client.source, client.store.stored_embedding)
for client in clients
if client.store.stored_embedding is not None
]
if len(recorded) < 2:
return
(first_name, first), *rest = recorded
for name, embedding in rest:
if embedding != first:
raise ConfigMismatchError(
f"databases '{first_name}' and '{name}' were written with "
f"different embedders ({_spell(first)} and "
f"{_spell(embedding)}); searching them together embeds the "
"query once, so their vectors are not comparable"
)
async def __aexit__(self, exc_type, exc_val, exc_tb): # noqa: ARG002
"""Async context manager exit.
Nothing to release is not an error: exiting before entering and exiting
twice both do nothing. The session stays readable afterwards, so what a
client covered can still be asked.
"""
if self._session is None or self._closed:
return False
# Branch on what this client covers, not on what it happened to open:
# a federating client that answered no query has nothing open and no
# store either.
if isinstance(self._session, FederatedSession):
# The wrappers only discard what they cached: the databases are the
# federated session's to close, and the reranker they searched with
# is this client's, closed below.
for facade in self._clients.values():
await facade._release_own()
self._clients.clear()
await self._session.aclose()
# This client's own: the reranker its facades borrow, and the
# embedder it builds from configuration for work that names no
# database. Each covered database builds and closes its own.
await self._aclose_cached("embedder")
await self._aclose_cached("_own_reranker")
self._closed = True
return False
if not self._owns_session:
await self._release_own()
return False
# The session drains, releases its store's embedder and closes; the
# cached reference here is only discarded, never closed twice.
await self._release_own()
await self._session.aclose()
self._closed = True
return False
async def _release_own(self) -> None:
"""Release what this client built, leaving the database to its owner.
The embedder belongs to the store and is closed with it, so the cached
reference is only discarded. A borrowed reranker belongs to its lender,
so only one built here is closed.
"""
self.__dict__.pop("embedder", None)
await self._aclose_cached("_own_reranker")
async def _aclose_cached(self, name: str) -> None:
"""Close a cached_property this client materialized, and discard it.
Re-entering the client builds a fresh one.
"""
cached = self.__dict__.pop(name, None)
if cached is not None:
await aclose_quietly(cached, name)
# =========================================================================
# Processing Primitives
# =========================================================================
@overload
async def convert(
self, source: Path, *, source_uri: str | None = None
) -> "DoclingDocument": ...
@overload
async def convert(
self, source: str, *, format: str = "md", source_uri: str | None = None
) -> "DoclingDocument": ...
async def convert(
self,
source: Path | str,
*,
format: str = "md",
source_uri: str | None = None,
) -> "DoclingDocument":
from haiku.rag.client.processing import convert
return await convert(self._config, source, format=format, source_uri=source_uri)
async def chunk(
self,
docling_document: "DoclingDocument",
*,
existing_picture_data: dict[str, bytes] | None = None,
document_id: str | None = None,
) -> list[Chunk]:
from haiku.rag.client.processing import chunk
return await chunk(
self._config,
docling_document,
embedder=self.embedder,
existing_picture_data=existing_picture_data,
document_id=document_id,
)
# =========================================================================
# Title Generation
# =========================================================================
async def generate_title(self, document: Document) -> str | None:
from haiku.rag.client.titles import generate_title
return await generate_title(self._config, document)
async def create_document(
self,
content: str,
uri: str | None = None,
title: str | None = None,
metadata: dict | None = None,
format: str = "md",
) -> Document:
from haiku.rag.client.documents import create_document
session = self._single_session("create_document")
return await create_document(session, content, uri, title, metadata, format)
async def import_document(
self,
docling_document: "DoclingDocument",
chunks: list[Chunk],
uri: str | None = None,
title: str | None = None,
metadata: dict | None = None,
) -> Document:
from haiku.rag.client.documents import import_document
session = self._single_session("import_document")
return await import_document(
session, docling_document, chunks, uri, title, metadata
)
async def import_documents(
self,
imports: "list[DocumentImport]",
) -> list[Document]:
from haiku.rag.client.documents import import_documents
session = self._single_session("import_documents")
return await import_documents(session, imports)
async def create_document_from_source(
self,
source: str | Path,
title: str | None = None,
metadata: dict | None = None,
uri: str | None = None,
storage_options: dict[str, str] | None = None,
sources: "list[Source] | None" = None,
source_id: str | None = None,
metadata_provider: "MetadataProvider | None" = None,
) -> Document | list[Document]:
from haiku.rag.client.documents import create_document_from_source
session = self._single_session("create_document_from_source")
return await create_document_from_source(
session,
source,
title,
metadata,
uri=uri,
storage_options=storage_options,
sources=sources,
source_id=source_id,
metadata_provider=metadata_provider,
)
async def update_document(
self,
document_id: str,
content: str | None = None,
metadata: dict | None = None,
chunks: list[Chunk] | None = None,
title: str | None = None,
docling_document: "DoclingDocument | None" = None,
uri: str | None = None,
) -> Document:
from haiku.rag.client.documents import update_document
session = self._single_session("update_document")
return await update_document(
session,
document_id,
content,
metadata,
chunks,
title,
docling_document,
uri,
)
async def get_document_by_id(
self, document_id: str, source: str | None = None
) -> Document | None:
"""Get a document by its ID, from the database named by `source`.
Args:
document_id: The unique identifier of the document.
source: The database it came from, which this client must cover.
Without one every covered database is asked, and ids repeat
between copies of a database, so a caller holding a source must
pass it.
Returns:
The Document instance if found, None otherwise.
Raises:
UnknownDatabaseError: If `source` names a database this client does
not cover.
"""
if source is not None:
(owner,) = await self.clients_covering([source])
return await owner.get_document_by_id(document_id)
if self.covers_multiple:
return await self._from_any_covered(
lambda owner: owner.get_document_by_id(document_id)
)
return await self._single_session("get_document_by_id").get_document_by_id(
document_id
)
async def get_chunk_by_id(
self, chunk_id: str, source: str | None = None
) -> Chunk | None:
"""Get a chunk by its ID, from the database named by `source`.
Args:
chunk_id: The unique identifier of the chunk.
source: The database it came from, which this client must cover.
Without one every covered database is asked, and ids repeat
between copies of a database, so a caller holding a source must
pass it.
Returns:
The Chunk instance if found, None otherwise.
Raises:
UnknownDatabaseError: If `source` names a database this client does
not cover.
"""
if source is not None:
(owner,) = await self.clients_covering([source])
return await owner.get_chunk_by_id(chunk_id)
if self.covers_multiple:
return await self._from_any_covered(
lambda owner: owner.get_chunk_by_id(chunk_id)
)
return await self.chunk_repository.get_by_id(chunk_id)
async def get_picture_bytes(
self, document_id: str, self_ref: str, source: str | None = None
) -> bytes | None:
"""Get a picture's bytes, from the database named by `source`.
Args:
document_id: The document holding the picture.
self_ref: The picture's `self_ref`.
source: The database it came from, which this client must cover.
Required when covering a set.
Returns:
The picture bytes if found, None otherwise.
Raises:
UnknownDatabaseError: If `source` names a database this client does
not cover.
"""
if source is not None:
(owner,) = await self.clients_covering([source])
return await owner.document_item_repository.get_picture_bytes(
document_id, self_ref
)
if self.covers_multiple:
raise ValueError(
"a picture lookup across databases needs the source it came from"
)
return await self.document_item_repository.get_picture_bytes(
document_id, self_ref
)
async def get_document_by_uri(self, uri: str) -> Document | None:
"""Get a document by its URI.
Args:
uri: The URI identifier of the document.
Returns:
The Document instance if found, None otherwise.
"""
if self.covers_multiple:
return await self._from_any_covered(
lambda owner: owner.get_document_by_uri(uri)
)
return await self._single_session("get_document_by_uri").get_document_by_uri(
uri
)
async def resolve_document(self, id_or_title: str) -> Document | None:
"""Resolve a document by ID, title, or URI (in that order).
Args:
id_or_title: Document ID, title, or URI to look up.
Returns:
The Document instance if found, None otherwise.
"""
doc = await self.get_document_by_id(id_or_title)
if doc:
return doc
safe_input = escape_sql_string(id_or_title)
for column in ("title", "uri"):
docs = await self.list_documents(filter=f"{column} = '{safe_input}'")
if docs and docs[0].id:
return await self.get_document_by_id(docs[0].id, docs[0].source)
return None
async def delete_document(self, document_id: str) -> bool:
"""Delete a document by its ID. Cascades to children linked via
``metadata.parent_uri``."""
return await self._single_session("delete_document").delete_document(
document_id
)
async def list_documents(
self,
limit: int | None = None,
offset: int | None = None,
filter: str | None = None,
include_content: bool = False,
) -> list[Document]:
"""List all documents with optional pagination and filtering.
Args:
limit: Maximum number of documents to return.
offset: Number of documents to skip.
filter: Optional SQL WHERE clause to filter documents.
include_content: Whether to load the text content. Defaults to
False. A listing never loads the docling blobs.
Returns:
List of Document instances matching the criteria.
"""
if self.covers_multiple:
# Each database is asked for enough rows to satisfy the window, and
# the window is applied to the merged listing: a limit means that
# many documents in total, not that many per database.
wanted = None if limit is None else limit + (offset or 0)
groups = await gather_all(
*(
owner.list_documents(
limit=wanted, filter=filter, include_content=include_content
)
for owner in await self.clients_covering()
)
)
# Round-robin: a window shows every database.
merged = [
doc for row in zip_longest(*groups) for doc in row if doc is not None
]
start = offset or 0
return merged[start:] if limit is None else merged[start : start + limit]
return await self._single_session("list_documents").list_documents(
limit=limit, offset=offset, filter=filter, include_content=include_content
)
async def count_documents(self, filter: str | None = None) -> int:
"""Count documents with optional filtering.
Args:
filter: Optional SQL WHERE clause to filter documents.
Returns:
Number of documents matching the criteria.
"""
if self.covers_multiple:
counts = await gather_all(
*(
owner.count_documents(filter=filter)
for owner in await self.clients_covering()
)
)
return sum(counts)
return await self.document_repository.count(filter=filter)
async def _from_any_covered(
self, lookup: "Callable[[HaikuRAG], Coroutine[Any, Any, Any]]"
) -> Any:
"""The first result `lookup` finds in the databases this client covers."""
found = await first_found(await self.clients_covering(), lookup)
return None if found is None else found[1]
def _require_known_sources(self, sources: "list[str] | None") -> None:
"""Fail on a name this client does not cover, opening nothing.
`clients_covering` answers the same question by opening the databases,
and a name is wrong whether or not what it names can be opened. `[]`
passes: a selection of nothing to search names nothing wrong.
"""
if sources is None:
return
covered = set(self.source_names)
unknown = [name for name in sources if name not in covered]
if unknown:
raise UnknownDatabaseError(
f"unknown database(s) {', '.join(sorted(set(unknown)))}; this "
f"client covers {', '.join(sorted(covered)) or 'a single unnamed database'}"
)
async def clients_covering(
self, sources: list[str] | None = None
) -> list["HaikuRAG"]:
"""The clients covering this selection.
The named subset for a client covering a set, or this one where it covers
a single database. Empty for a selection of none, which is not the same as
`None` for all of them. Every read honouring `sources` decides through
this, so the rule cannot differ between one operation and another.
"""
if self.covers_multiple:
return await self.clients_for(
list(self.source_names) if sources is None else sources
)
if sources is None:
return [self]
sources = _without_repeats(sources)
if not sources:
return []
if sources != [self.source]:
raise UnknownDatabaseError(
f"unknown database(s) {', '.join(sources) or '(none)'}; this "
f"client covers {self.source or 'a single unnamed database'}"
)
return [self]
async def search(
self,
query: "str | bytes | PILImage.Image",
limit: int | None = None,
search_type: SearchType | None = None,
filter: str | None = None,
include_images: bool = True,
sources: list[str] | None = None,
) -> list[SearchResult]:
from haiku.rag.client.search import search, search_sources
if self.covers_multiple:
return await search_sources(
self, query, limit, search_type, filter, include_images, sources
)
if not await self.clients_covering(sources):
return []
results = await search(self, query, limit, search_type, filter, include_images)
# A database named in config keeps its name even when it is the only one
# this client covers. Only an unnamed `lancedb.uri` database leaves
# source unset.
for result in results:
result.source = self.source
return results
async def expand_context(
self,
search_results: list[SearchResult],
) -> list[SearchResult]:
from haiku.rag.client.search import expand_context, expand_sources
if isinstance(self._session, FederatedSession):
return await expand_sources(self._session, search_results)
return await expand_context(
self._single_session("expand_context"), search_results
)
async def ask(
self,
question: str,
filter: str | None = None,
images: Sequence[bytes] | None = None,
sources: list[str] | None = None,
) -> "tuple[str, list[Citation]]":
from haiku.rag.client.agents import ask
return await ask(self, question, filter, images, sources)
async def analyze(
self,
question: str,
filter: str | None = None,
images: Sequence[bytes] | None = None,
sources: list[str] | None = None,
) -> "AnalysisResult":
from haiku.rag.client.agents import analyze
return await analyze(self, question, filter, images, sources)
async def visualize_chunk(
self,
chunk: Chunk | Sequence[Chunk],
refs: list[str] | None = None,
expand: bool = True,
) -> list:
from haiku.rag.client.search import visualize_chunk
return await visualize_chunk(
self._single_session("visualize_chunk"), chunk, refs, expand
)
async def rebuild_database(
self, mode: RebuildMode = RebuildMode.FULL
) -> AsyncGenerator[str, None]:
from haiku.rag.client.rebuild import rebuild_database
session = self._single_session("rebuild_database")
async for doc_id in rebuild_database(session, mode):
yield doc_id
async def vacuum(self) -> None:
"""Optimize and clean up old versions across all tables."""
await self._single_session("vacuum").store.vacuum()
async def aclose(self) -> None:
"""Release everything this client opened, whatever it covers.
The teardown `async with` runs, for a caller that owns the client's
lifetime some other way. Nothing to release is not an error, so this is
safe before entering and after closing.
"""
await self.__aexit__(None, None, None)
def close(self) -> None:
"""Close the connection to the one database this client opened.
The connection and nothing else: draining the background vacuum and
releasing the embedder and reranker are awaitable, so `aclose` is what
does all of it, and `async with` is the usual way to ask for it.
A client covering one of a set borrows that database and never closes
it: the set opened it and the set closes it. A client covering a set has
no single connection to close and refuses.
"""
if not isinstance(self._session, SingleDatabaseSession):
raise AmbiguousDatabaseError(
"close works on one connection, and this client covers "
f"{', '.join(sorted(self.source_names))}; await aclose() to "
"release every database it opened"
)
if not self._owns_session:
return
self._session.close()