Add hy_memory.reembed for switching the embedding model while writes are paused. Source and target are named explicitly; a space's identity is the model name plus the width. - vector_space_names, plan_reembed, reembed_vector_space (resumable, cancellable at batch boundaries, widths validated before any upsert, target wins on existing ids, source never written), verify_reembed, drop_vector_space. - scan_vector_spaces and diff_spaces for MemHub's startup reconcile of stray spaces. - reembed_graph builds a new Kuzu DB at the target width, copies every node and edge, recomputes Memory/beh/Topic/HierarchyTopic embeddings and rebuilds the HNSW index; the source graph is opened read-only. verify_graph_reembed and drop_graph. EmbedService.embed_batch now chunks to embedder.batch_size (default 64), honours Retry-After on 429, takes a per-call max_retries, and rejects vectors whose width differs from embedding_dims. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1778 lines
67 KiB
Python
1778 lines
67 KiB
Python
"""Re-embed the SDK's stores into a new embedding space.
|
|
|
|
An embedding space is the set of physical collections (and, in ultra mode, the
|
|
Kuzu graph) whose vectors were computed by one embedding model at one width.
|
|
Switching the model means computing every vector again with the new model and
|
|
writing it into a *new* space. The stored text is enough for that: each record
|
|
keeps its document and metadata, and the text that was embedded can be rebuilt
|
|
from them (`reembed_text`). The original conversations are not needed.
|
|
|
|
Contract:
|
|
|
|
- The caller pauses every writer before calling into this module and keeps
|
|
them paused until the switch commits. The source stores are treated as frozen.
|
|
- Nothing here writes to the source. Vector sources are only read; the source
|
|
graph is opened read-only.
|
|
- Source and target are named explicitly (`VectorSpace`, graph paths). Nothing
|
|
is derived from the embedding provider or endpoint; the identity of a space is
|
|
the model name plus the width (`space_identity`).
|
|
- Vector re-embeds are resumable: a target record with the same id is skipped
|
|
(see `skip_existing`). Graph re-embeds restart from scratch: the target is
|
|
built at a staging path that is wiped on every start and moved into place
|
|
only after the copy verifies.
|
|
|
|
Only the Chroma vector backend and the Kuzu graph backend are supported.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import concurrent.futures
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import random
|
|
import re
|
|
import shutil
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import (
|
|
Any,
|
|
Callable,
|
|
Dict,
|
|
Iterable,
|
|
List,
|
|
Literal,
|
|
Mapping,
|
|
Optional,
|
|
Sequence,
|
|
Tuple,
|
|
Union,
|
|
)
|
|
|
|
from .config import MemoryConfig
|
|
from .embedding import EmbeddingDimensionMismatchError, EmbeddingService
|
|
from .utils.async_executor import run_in_executor_guarded
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
VectorRole = Literal[
|
|
"memory", "entities",
|
|
"coding_keys", "coding_keys_cv",
|
|
"working_facts", "working_facts_cv",
|
|
]
|
|
VECTOR_ROLES: Tuple[str, ...] = (
|
|
"memory", "entities",
|
|
"coding_keys", "coding_keys_cv",
|
|
"working_facts", "working_facts_cv",
|
|
)
|
|
|
|
# Collection metadata keys written on target collections this module creates.
|
|
# Records themselves are copied verbatim, so the model can only live here.
|
|
COLLECTION_MODEL_KEY = "hy_embedding_model"
|
|
COLLECTION_DIMS_KEY = "hy_embedding_dims"
|
|
|
|
_SELF_RECALL_WARN_RATE = 0.9
|
|
|
|
|
|
class ReembedCancelled(RuntimeError):
|
|
"""Raised at a batch boundary when ``cancel()`` returns True.
|
|
|
|
Every batch written before the cancel is complete; no batch is half-written.
|
|
``report`` holds the progress made so far (a ``ReembedReport`` for vectors,
|
|
``None`` for the graph, whose staging copy is discarded).
|
|
"""
|
|
|
|
def __init__(self, message: str = "reembed cancelled", report: Any = None) -> None:
|
|
super().__init__(message)
|
|
self.report = report
|
|
|
|
|
|
# ======================================================================
|
|
# Space identity and naming
|
|
# ======================================================================
|
|
|
|
@dataclass(frozen=True)
|
|
class EmbeddingSpaceIdentity:
|
|
"""What makes two embedding spaces the same: model name and width."""
|
|
|
|
model: str
|
|
dims: int
|
|
|
|
@property
|
|
def key(self) -> str:
|
|
return f"{self.model}#{self.dims}"
|
|
|
|
@property
|
|
def digest(self) -> str:
|
|
"""Short stable hash, usable inside a collection or directory name."""
|
|
return hashlib.sha256(self.key.encode("utf-8")).hexdigest()[:12]
|
|
|
|
|
|
def space_identity(model: str, dims: int) -> EmbeddingSpaceIdentity:
|
|
"""Identity of an embedding space. Provider and endpoint are not part of it."""
|
|
name = " ".join(str(model or "").split())
|
|
if not name:
|
|
raise ValueError("space_identity needs a model name")
|
|
width = int(dims)
|
|
if width <= 0:
|
|
raise ValueError(f"space_identity needs a positive width, got {dims!r}")
|
|
return EmbeddingSpaceIdentity(model=name, dims=width)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class VectorSpace:
|
|
"""One embedding space in a Chroma store, named explicitly.
|
|
|
|
``base_name`` is the collection base without the width suffix, exactly what
|
|
``vector_store.collection_name`` is for a client. ``collections`` overrides
|
|
the physical name per role when the caller manages names itself; roles left
|
|
out of an explicit mapping are not part of the space.
|
|
"""
|
|
|
|
base_name: str
|
|
dims: Optional[int]
|
|
persist_directory: Optional[str] = None
|
|
host: Optional[str] = None
|
|
port: Optional[int] = None
|
|
model: Optional[str] = None
|
|
collections: Optional[Mapping[str, str]] = None
|
|
|
|
@classmethod
|
|
def from_config(
|
|
cls, config: MemoryConfig, *, base_name: Optional[str] = None,
|
|
) -> "VectorSpace":
|
|
vs = config.vector_store
|
|
provider = str(vs.provider or "chroma").lower()
|
|
if provider != "chroma":
|
|
raise NotImplementedError(
|
|
f"re-embed supports the chroma vector store only, got {provider!r}"
|
|
)
|
|
return cls(
|
|
base_name=base_name or vs.collection_name or "agent_memories",
|
|
dims=vs.embedding_dims,
|
|
persist_directory=vs.persist_directory,
|
|
host=vs.host,
|
|
port=vs.port,
|
|
model=getattr(config.embedder, "model", None),
|
|
)
|
|
|
|
def names(self) -> Dict[str, str]:
|
|
if self.collections is not None:
|
|
unknown = set(self.collections) - set(VECTOR_ROLES)
|
|
if unknown:
|
|
raise ValueError(f"unknown vector roles: {sorted(unknown)}")
|
|
return {role: self.collections[role] for role in VECTOR_ROLES if role in self.collections}
|
|
return _derived_names(self.base_name, self.dims)
|
|
|
|
def location(self) -> Tuple[str, str]:
|
|
if self.host:
|
|
return ("http", f"{self.host}:{self.port or 8000}")
|
|
return ("local", os.path.realpath(str(self.persist_directory or "")))
|
|
|
|
|
|
SpaceLike = Union[VectorSpace, MemoryConfig]
|
|
|
|
|
|
def _derived_names(base_name: str, dims: Optional[int]) -> Dict[str, str]:
|
|
from .coding.store import _derive_coding_collection_name, _derive_fact_collection_name
|
|
from .data.vector_store_base import physical_collection_name
|
|
from .pipelines._retrieval.config import entity_collection_name
|
|
|
|
memory = physical_collection_name(base_name, dims)
|
|
return {
|
|
"memory": memory,
|
|
"entities": entity_collection_name(memory),
|
|
"coding_keys": physical_collection_name(_derive_coding_collection_name(base_name, ""), dims),
|
|
"coding_keys_cv": physical_collection_name(_derive_coding_collection_name(base_name, "_cv"), dims),
|
|
"working_facts": physical_collection_name(_derive_fact_collection_name(base_name, ""), dims),
|
|
"working_facts_cv": physical_collection_name(_derive_fact_collection_name(base_name, "_cv"), dims),
|
|
}
|
|
|
|
|
|
def _space(value: SpaceLike) -> VectorSpace:
|
|
if isinstance(value, VectorSpace):
|
|
return value
|
|
if isinstance(value, MemoryConfig):
|
|
return VectorSpace.from_config(value)
|
|
raise TypeError(f"expected VectorSpace or MemoryConfig, got {type(value).__name__}")
|
|
|
|
|
|
def vector_space_names(
|
|
config: SpaceLike, *, base_name: Optional[str] = None,
|
|
) -> Dict[str, str]:
|
|
"""Every physical collection a client with ``config`` opens for the vector roles.
|
|
|
|
The width suffix is included. Pass ``base_name`` to name a space other than
|
|
``config.vector_store.collection_name``.
|
|
"""
|
|
if isinstance(config, MemoryConfig):
|
|
return VectorSpace.from_config(config, base_name=base_name).names()
|
|
space = _space(config)
|
|
if base_name is not None:
|
|
return _derived_names(base_name, space.dims)
|
|
return space.names()
|
|
|
|
|
|
# ======================================================================
|
|
# The text a record was embedded from
|
|
# ======================================================================
|
|
|
|
def _custom_dict(value: Any) -> Dict[str, Any]:
|
|
if isinstance(value, dict):
|
|
return value
|
|
if isinstance(value, str) and value:
|
|
try:
|
|
parsed = json.loads(value)
|
|
except (TypeError, ValueError):
|
|
return {}
|
|
return parsed if isinstance(parsed, dict) else {}
|
|
return {}
|
|
|
|
|
|
def reembed_text(
|
|
role: str, document: Optional[str], metadata: Optional[Mapping[str, Any]],
|
|
) -> str:
|
|
"""The exact text the write path embedded for one vector record.
|
|
|
|
- memory, ``custom.memory_kind == "canonical_state"``: ``custom.state_retrieval_text``
|
|
- memory with a non-empty ``custom.retrieval_text`` (schemas): that text
|
|
- everything else, coding keys and working facts included: the document,
|
|
falling back to ``metadata["content"]``
|
|
"""
|
|
meta = metadata or {}
|
|
if role == "memory":
|
|
custom = _custom_dict(meta.get("custom"))
|
|
if custom.get("memory_kind") == "canonical_state":
|
|
text = str(custom.get("state_retrieval_text") or "").strip()
|
|
if text:
|
|
return text
|
|
text = str(custom.get("retrieval_text") or "").strip()
|
|
if text:
|
|
return text
|
|
if document:
|
|
return document
|
|
return str(meta.get("content") or "")
|
|
|
|
|
|
def _embeddable(text: str) -> str:
|
|
# OpenAI-compatible services reject an empty input with a 400 that would
|
|
# fail the whole batch. A record with no text has no meaning to preserve.
|
|
return text if text and text.strip() else " "
|
|
|
|
|
|
def _record_digest(document: Optional[str], metadata: Optional[Mapping[str, Any]]) -> str:
|
|
payload = json.dumps(
|
|
[document, dict(metadata) if metadata is not None else None],
|
|
sort_keys=True, ensure_ascii=False, default=str, separators=(",", ":"),
|
|
)
|
|
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
|
|
|
|
# ======================================================================
|
|
# Chroma access (read-only on the source)
|
|
# ======================================================================
|
|
|
|
_VECTOR_PAGE = 500
|
|
|
|
|
|
async def _vdb(func, *args, **kwargs):
|
|
from .data.vector_store_chroma import _run_in_vdb_pool
|
|
return await _run_in_vdb_pool(func, *args, **kwargs)
|
|
|
|
|
|
def _client_for(space: VectorSpace):
|
|
import chromadb
|
|
|
|
if space.host:
|
|
return chromadb.HttpClient(host=space.host, port=space.port or 8000)
|
|
# Same settings as ChromaVectorStore.initialize, so both share one system.
|
|
return chromadb.PersistentClient(path=space.persist_directory)
|
|
|
|
|
|
def _get_collection(client, name: str):
|
|
try:
|
|
return client.get_collection(name=name)
|
|
except Exception as exc: # chromadb NotFoundError / ValueError by version
|
|
if "does not exist" in str(exc).lower() or type(exc).__name__ in (
|
|
"NotFoundError", "InvalidCollectionException",
|
|
):
|
|
return None
|
|
raise
|
|
|
|
|
|
def _page(collection, offset: int, limit: int, include: Sequence[str]) -> Dict[str, Any]:
|
|
return collection.get(limit=limit, offset=offset, include=list(include))
|
|
|
|
|
|
def _iter_pages(collection, include: Sequence[str], page: int = _VECTOR_PAGE):
|
|
total = collection.count()
|
|
offset = 0
|
|
while offset < total:
|
|
got = _page(collection, offset, page, include)
|
|
ids = got.get("ids") or []
|
|
if not ids:
|
|
break
|
|
yield got
|
|
offset += len(ids)
|
|
|
|
|
|
def _first_width(collection) -> Optional[int]:
|
|
got = collection.get(limit=1, include=["embeddings"])
|
|
embeddings = got.get("embeddings")
|
|
if embeddings is None or len(embeddings) == 0:
|
|
return None
|
|
return len(embeddings[0])
|
|
|
|
|
|
def _check_not_same(source: VectorSpace, target: VectorSpace, src_name: str, tgt_name: str) -> None:
|
|
if source.location() == target.location() and src_name == tgt_name:
|
|
raise ValueError(
|
|
f"source and target are the same collection {src_name!r}; "
|
|
"re-embed always writes a new collection"
|
|
)
|
|
|
|
|
|
def _roles(space: VectorSpace, roles: Optional[Iterable[str]]) -> List[str]:
|
|
names = space.names()
|
|
if roles is None:
|
|
return [role for role in VECTOR_ROLES if role in names]
|
|
wanted = list(roles)
|
|
unknown = [role for role in wanted if role not in VECTOR_ROLES]
|
|
if unknown:
|
|
raise ValueError(f"unknown vector roles: {unknown}")
|
|
return [role for role in wanted if role in names]
|
|
|
|
|
|
# ======================================================================
|
|
# Plan
|
|
# ======================================================================
|
|
|
|
@dataclass(frozen=True)
|
|
class ReembedCollectionPlan:
|
|
role: str
|
|
source_name: str
|
|
target_name: str
|
|
source_count: int
|
|
target_exists: bool
|
|
target_count: int
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class GraphReembedPlan:
|
|
source_path: str
|
|
node_counts: Dict[str, int]
|
|
rel_counts: Dict[str, int]
|
|
memory_embeddings: int
|
|
beh_embeddings: int
|
|
topic_embeddings: int
|
|
hierarchy_topic_embeddings: int
|
|
source_width: Optional[int]
|
|
target_width: Optional[int]
|
|
|
|
@property
|
|
def memory_nodes(self) -> int:
|
|
return self.node_counts.get("Memory", 0)
|
|
|
|
@property
|
|
def topics(self) -> int:
|
|
return self.node_counts.get("Topic", 0)
|
|
|
|
@property
|
|
def hierarchy_topics(self) -> int:
|
|
return self.node_counts.get("HierarchyTopic", 0)
|
|
|
|
@property
|
|
def vectors(self) -> int:
|
|
return (
|
|
self.memory_embeddings + self.beh_embeddings
|
|
+ self.topic_embeddings + self.hierarchy_topic_embeddings
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ReembedPlan:
|
|
collections: Tuple[ReembedCollectionPlan, ...]
|
|
total_vectors: int
|
|
source_dims: Optional[int]
|
|
target_dims: Optional[int]
|
|
graph: Optional[GraphReembedPlan] = None
|
|
|
|
@property
|
|
def estimated_vectors(self) -> int:
|
|
"""Vectors to compute: every vector record plus every graph embedding."""
|
|
return self.total_vectors + (self.graph.vectors if self.graph else 0)
|
|
|
|
@property
|
|
def per_role(self) -> Dict[str, int]:
|
|
return {item.role: item.source_count for item in self.collections}
|
|
|
|
|
|
def _plan_sync(source: VectorSpace, target: VectorSpace) -> Tuple[List[ReembedCollectionPlan], Optional[int]]:
|
|
src_client = _client_for(source)
|
|
tgt_client = _client_for(target)
|
|
src_names, tgt_names = source.names(), target.names()
|
|
out: List[ReembedCollectionPlan] = []
|
|
source_dims: Optional[int] = None
|
|
for role in VECTOR_ROLES:
|
|
if role not in src_names or role not in tgt_names:
|
|
continue
|
|
src = _get_collection(src_client, src_names[role])
|
|
tgt = _get_collection(tgt_client, tgt_names[role])
|
|
count = src.count() if src is not None else 0
|
|
if src is not None and count and source_dims is None:
|
|
source_dims = _first_width(src)
|
|
out.append(ReembedCollectionPlan(
|
|
role=role,
|
|
source_name=src_names[role],
|
|
target_name=tgt_names[role],
|
|
source_count=count,
|
|
target_exists=tgt is not None,
|
|
target_count=tgt.count() if tgt is not None else 0,
|
|
))
|
|
return out, source_dims
|
|
|
|
|
|
async def plan_reembed(
|
|
source: SpaceLike,
|
|
target: SpaceLike,
|
|
*,
|
|
source_graph_path: Optional[Union[str, Path]] = None,
|
|
target_graph_dims: Optional[int] = None,
|
|
) -> ReembedPlan:
|
|
"""Per-role record counts and the number of vectors a re-embed computes.
|
|
|
|
Pass ``source_graph_path`` to include the Kuzu graph. Read-only.
|
|
"""
|
|
src, tgt = _space(source), _space(target)
|
|
collections, source_dims = await _vdb(_plan_sync, src, tgt)
|
|
graph = None
|
|
if source_graph_path is not None and Path(source_graph_path).exists():
|
|
graph = await plan_graph_reembed(
|
|
source_graph_path, target_dims=target_graph_dims or tgt.dims,
|
|
)
|
|
return ReembedPlan(
|
|
collections=tuple(collections),
|
|
total_vectors=sum(item.source_count for item in collections),
|
|
source_dims=source_dims if source_dims is not None else src.dims,
|
|
target_dims=tgt.dims,
|
|
graph=graph,
|
|
)
|
|
|
|
|
|
# ======================================================================
|
|
# Vector re-embed
|
|
# ======================================================================
|
|
|
|
@dataclass(frozen=True)
|
|
class ReembedProgress:
|
|
role: str
|
|
done: int # source records handled so far
|
|
total: int # source records in this role
|
|
embedded: int # vectors computed and written
|
|
reused: int # skipped: the target already holds an identical record
|
|
kept: int = 0 # skipped: the target holds a different record with this id (target wins)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ReembedReport:
|
|
per_role: Dict[str, ReembedProgress]
|
|
elapsed_s: float
|
|
|
|
@property
|
|
def embedded(self) -> int:
|
|
return sum(item.embedded for item in self.per_role.values())
|
|
|
|
@property
|
|
def reused(self) -> int:
|
|
return sum(item.reused for item in self.per_role.values())
|
|
|
|
@property
|
|
def kept(self) -> int:
|
|
return sum(item.kept for item in self.per_role.values())
|
|
|
|
|
|
def _default_embedding(target: SpaceLike) -> EmbeddingService:
|
|
if isinstance(target, MemoryConfig):
|
|
from .core.embed_service import EmbedService
|
|
return EmbedService(target)
|
|
raise ValueError("pass embedding= when the target is a VectorSpace")
|
|
|
|
|
|
def _check_service_dims(embedding: Any, dims: int) -> None:
|
|
getter = getattr(embedding, "get_embedding_dims", None)
|
|
if not callable(getter):
|
|
return
|
|
try:
|
|
declared = getter()
|
|
except Exception:
|
|
return
|
|
if isinstance(declared, int) and declared and declared != dims:
|
|
raise EmbeddingDimensionMismatchError(
|
|
f"embedding service width {declared} != target width {dims}"
|
|
)
|
|
|
|
|
|
def _validate_vectors(vectors: Sequence[Any], expected: int, dims: int, where: str) -> List[List[float]]:
|
|
if len(vectors) != expected:
|
|
raise ValueError(f"{where}: expected {expected} vectors, got {len(vectors)}")
|
|
out: List[List[float]] = []
|
|
for vector in vectors:
|
|
width = len(vector) if vector is not None else 0
|
|
if width != dims:
|
|
raise EmbeddingDimensionMismatchError(
|
|
f"{where}: embedding width {width} != target width {dims}"
|
|
)
|
|
out.append([float(x) for x in vector])
|
|
return out
|
|
|
|
|
|
async def _embed(embedding: Any, texts: List[str]) -> List[Any]:
|
|
if not texts:
|
|
return []
|
|
return list(await embedding.embed_batch(texts, use_cache=False))
|
|
|
|
|
|
def _target_metadata(source_collection, target: VectorSpace) -> Dict[str, Any]:
|
|
metadata = dict(getattr(source_collection, "metadata", None) or {})
|
|
metadata.setdefault("hnsw:space", "cosine")
|
|
metadata.pop(COLLECTION_MODEL_KEY, None)
|
|
metadata.pop(COLLECTION_DIMS_KEY, None)
|
|
if target.model:
|
|
metadata[COLLECTION_MODEL_KEY] = str(target.model)
|
|
if target.dims:
|
|
metadata[COLLECTION_DIMS_KEY] = int(target.dims)
|
|
return metadata
|
|
|
|
|
|
async def reembed_vector_space(
|
|
source: SpaceLike,
|
|
target: SpaceLike,
|
|
*,
|
|
embedding: Optional[EmbeddingService] = None,
|
|
batch_size: int = 64,
|
|
max_inflight: int = 2,
|
|
roles: Optional[Iterable[str]] = None,
|
|
cancel: Optional[Callable[[], bool]] = None,
|
|
on_progress: Optional[Callable[[ReembedProgress], None]] = None,
|
|
skip_existing: bool = True,
|
|
) -> ReembedReport:
|
|
"""Re-embed every record of ``source`` into ``target`` with ``embedding``.
|
|
|
|
All users and all layers (L1_RAW included) are read by paging the raw
|
|
collections. For each record the text from `reembed_text` is embedded and
|
|
the record is upserted into the target with the same id, document and
|
|
metadata, copied verbatim.
|
|
|
|
- Every vector's width is checked against ``target.dims`` before anything
|
|
in its batch is written (`EmbeddingDimensionMismatchError`).
|
|
- ``skip_existing=True`` (default): an id the target already holds is never
|
|
touched, so a rerun resumes and a merge of a stray space never overwrites
|
|
live data (the target wins). ``skip_existing=False``: identical records
|
|
are still skipped, records that differ are re-embedded from the source.
|
|
- ``cancel`` is checked before each round of up to ``max_inflight`` batches;
|
|
a cancel raises `ReembedCancelled` with every finished batch written.
|
|
- The source is never written.
|
|
"""
|
|
src, tgt = _space(source), _space(target)
|
|
if not tgt.dims:
|
|
raise ValueError("the target space needs an explicit width (dims)")
|
|
dims = int(tgt.dims)
|
|
service = embedding if embedding is not None else _default_embedding(target)
|
|
_check_service_dims(service, dims)
|
|
batch_size = max(1, int(batch_size))
|
|
max_inflight = max(1, int(max_inflight))
|
|
cancelled = cancel or (lambda: False)
|
|
src_names, tgt_names = src.names(), tgt.names()
|
|
selected = [role for role in _roles(src, roles) if role in tgt_names]
|
|
started = time.monotonic()
|
|
per_role: Dict[str, ReembedProgress] = {}
|
|
|
|
def _report() -> ReembedReport:
|
|
return ReembedReport(per_role=dict(per_role), elapsed_s=time.monotonic() - started)
|
|
|
|
src_client = await _vdb(_client_for, src)
|
|
tgt_client = await _vdb(_client_for, tgt)
|
|
for role in selected:
|
|
src_name, tgt_name = src_names[role], tgt_names[role]
|
|
_check_not_same(src, tgt, src_name, tgt_name)
|
|
src_coll = await _vdb(_get_collection, src_client, src_name)
|
|
total = await _vdb(src_coll.count) if src_coll is not None else 0
|
|
progress = ReembedProgress(role=role, done=0, total=total, embedded=0, reused=0, kept=0)
|
|
per_role[role] = progress
|
|
if not total:
|
|
if on_progress:
|
|
on_progress(progress)
|
|
continue
|
|
tgt_coll = await _vdb(
|
|
tgt_client.get_or_create_collection,
|
|
name=tgt_name,
|
|
metadata=_target_metadata(src_coll, tgt),
|
|
)
|
|
offset = 0
|
|
while offset < total:
|
|
if cancelled():
|
|
raise ReembedCancelled(report=_report())
|
|
# One round: read up to max_inflight batches, embed them together,
|
|
# validate all of them, then write them in order.
|
|
batches: List[Dict[str, Any]] = []
|
|
for _ in range(max_inflight):
|
|
if offset >= total:
|
|
break
|
|
got = await _vdb(_page, src_coll, offset, batch_size, ["documents", "metadatas"])
|
|
ids = list(got.get("ids") or [])
|
|
if not ids:
|
|
offset = total
|
|
break
|
|
offset += len(ids)
|
|
batches.append({
|
|
"ids": ids,
|
|
"documents": list(got.get("documents") or [None] * len(ids)),
|
|
"metadatas": list(got.get("metadatas") or [None] * len(ids)),
|
|
})
|
|
if not batches:
|
|
break
|
|
reused = kept = 0
|
|
for batch in batches:
|
|
existing = await _vdb(
|
|
tgt_coll.get, ids=batch["ids"], include=["documents", "metadatas"],
|
|
)
|
|
have = {
|
|
rid: _record_digest(doc, meta)
|
|
for rid, doc, meta in zip(
|
|
existing.get("ids") or [],
|
|
existing.get("documents") or [],
|
|
existing.get("metadatas") or [],
|
|
)
|
|
}
|
|
pending = []
|
|
for i, rid in enumerate(batch["ids"]):
|
|
doc, meta = batch["documents"][i], batch["metadatas"][i]
|
|
if rid in have:
|
|
if have[rid] == _record_digest(doc, meta):
|
|
reused += 1
|
|
continue
|
|
if skip_existing:
|
|
kept += 1
|
|
continue
|
|
pending.append(i)
|
|
batch["pending"] = pending
|
|
batch["texts"] = [
|
|
_embeddable(reembed_text(role, batch["documents"][i], batch["metadatas"][i]))
|
|
for i in pending
|
|
]
|
|
results = await asyncio.gather(*(_embed(service, b["texts"]) for b in batches))
|
|
checked = [
|
|
_validate_vectors(vectors, len(b["pending"]), dims, f"{role}:{tgt_name}")
|
|
for b, vectors in zip(batches, results)
|
|
]
|
|
embedded = 0
|
|
for batch, vectors in zip(batches, checked):
|
|
if not batch["pending"]:
|
|
continue
|
|
idx = batch["pending"]
|
|
await _vdb(
|
|
tgt_coll.upsert,
|
|
ids=[batch["ids"][i] for i in idx],
|
|
embeddings=vectors,
|
|
documents=[batch["documents"][i] for i in idx],
|
|
metadatas=[batch["metadatas"][i] for i in idx],
|
|
)
|
|
embedded += len(idx)
|
|
done = sum(len(b["ids"]) for b in batches)
|
|
progress = ReembedProgress(
|
|
role=role,
|
|
done=progress.done + done,
|
|
total=total,
|
|
embedded=progress.embedded + embedded,
|
|
reused=progress.reused + reused,
|
|
kept=progress.kept + kept,
|
|
)
|
|
per_role[role] = progress
|
|
if on_progress:
|
|
on_progress(progress)
|
|
report = _report()
|
|
logger.info(
|
|
"[reembed] vector space done: embedded=%d reused=%d kept=%d elapsed=%.1fs",
|
|
report.embedded, report.reused, report.kept, report.elapsed_s,
|
|
)
|
|
return report
|
|
|
|
|
|
# ======================================================================
|
|
# Scan and diff (MemHub startup reconcile)
|
|
# ======================================================================
|
|
|
|
_ROLE_SUFFIX = re.compile(
|
|
r"^(?P<base>.+?)_(?P<role>coding_keys_cv|coding_keys|working_facts_cv|working_facts)"
|
|
r"(?:_(?P<dims>\d+))?$"
|
|
)
|
|
_DIMS_SUFFIX = re.compile(r"^(?P<base>.+)_(?P<dims>\d+)$")
|
|
|
|
|
|
def parse_collection_name(name: str) -> Optional[Tuple[str, Optional[int], str]]:
|
|
"""Split a physical collection name into (base, dims, role).
|
|
|
|
Returns None for collections that are not vector roles (``_tag_index``).
|
|
"""
|
|
if name.endswith("_tag_index"):
|
|
return None
|
|
match = _ROLE_SUFFIX.match(name)
|
|
if match:
|
|
dims = match.group("dims")
|
|
return match.group("base"), int(dims) if dims else None, match.group("role")
|
|
role = "memory"
|
|
if name.endswith("_entities"):
|
|
name, role = name[: -len("_entities")], "entities"
|
|
match = _DIMS_SUFFIX.match(name)
|
|
if match:
|
|
return match.group("base"), int(match.group("dims")), role
|
|
return name, None, role
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ScannedCollection:
|
|
role: str
|
|
name: str
|
|
count: int
|
|
width: Optional[int]
|
|
per_user: Dict[str, int]
|
|
models: Tuple[str, ...]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ScannedSpace:
|
|
base_name: str
|
|
dims: Optional[int]
|
|
roles: Dict[str, ScannedCollection]
|
|
persist_directory: str
|
|
|
|
@property
|
|
def total(self) -> int:
|
|
return sum(item.count for item in self.roles.values())
|
|
|
|
@property
|
|
def width(self) -> Optional[int]:
|
|
"""The stored width when every non-empty role agrees, else None."""
|
|
widths = {item.width for item in self.roles.values() if item.width}
|
|
return widths.pop() if len(widths) == 1 else None
|
|
|
|
@property
|
|
def per_user(self) -> Dict[str, int]:
|
|
out: Dict[str, int] = {}
|
|
for item in self.roles.values():
|
|
for user, count in item.per_user.items():
|
|
out[user] = out.get(user, 0) + count
|
|
return out
|
|
|
|
@property
|
|
def models(self) -> Tuple[str, ...]:
|
|
return tuple(sorted({m for item in self.roles.values() for m in item.models}))
|
|
|
|
def space(self, *, model: Optional[str] = None) -> VectorSpace:
|
|
"""A `VectorSpace` naming exactly the collections found."""
|
|
return VectorSpace(
|
|
base_name=self.base_name,
|
|
dims=self.dims if self.dims is not None else self.width,
|
|
persist_directory=self.persist_directory,
|
|
model=model,
|
|
collections={role: item.name for role, item in self.roles.items()},
|
|
)
|
|
|
|
|
|
def _scan_sync(persist_directory: str, per_user: bool) -> List[ScannedSpace]:
|
|
if not os.path.isdir(persist_directory):
|
|
return []
|
|
import chromadb
|
|
|
|
client = chromadb.PersistentClient(path=persist_directory)
|
|
grouped: Dict[Tuple[str, Optional[int]], Dict[str, ScannedCollection]] = {}
|
|
for item in client.list_collections():
|
|
name = item if isinstance(item, str) else item.name
|
|
parsed = parse_collection_name(name)
|
|
if parsed is None:
|
|
continue
|
|
base, dims, role = parsed
|
|
collection = client.get_collection(name=name)
|
|
count = collection.count()
|
|
users: Dict[str, int] = {}
|
|
models = set()
|
|
model = (collection.metadata or {}).get(COLLECTION_MODEL_KEY)
|
|
if model:
|
|
models.add(str(model))
|
|
if per_user and count:
|
|
for got in _iter_pages(collection, ["metadatas"], page=2000):
|
|
for meta in got.get("metadatas") or []:
|
|
meta = meta or {}
|
|
user = str(meta.get("user_id") or "")
|
|
users[user] = users.get(user, 0) + 1
|
|
record_model = meta.get(COLLECTION_MODEL_KEY) or meta.get("embedding_model")
|
|
if record_model:
|
|
models.add(str(record_model))
|
|
grouped.setdefault((base, dims), {})[role] = ScannedCollection(
|
|
role=role,
|
|
name=name,
|
|
count=count,
|
|
width=_first_width(collection) if count else None,
|
|
per_user=users,
|
|
models=tuple(sorted(models)),
|
|
)
|
|
return [
|
|
ScannedSpace(base_name=base, dims=dims, roles=roles, persist_directory=persist_directory)
|
|
for (base, dims), roles in sorted(grouped.items(), key=lambda kv: (kv[0][0], kv[0][1] or 0))
|
|
]
|
|
|
|
|
|
async def scan_vector_spaces(
|
|
persist_directory: Union[str, Path], *, per_user: bool = True,
|
|
) -> List[ScannedSpace]:
|
|
"""Every embedding space with collections under ``persist_directory``.
|
|
|
|
Read-only. Collections are grouped by (base, width) from their names; for
|
|
each role found the scan reports the record count, the stored width (read
|
|
from one embedding), the per-``user_id`` counts and the models recorded in
|
|
collection or record metadata (often none: records do not carry the model,
|
|
only collections created by `reembed_vector_space` do).
|
|
"""
|
|
return await _vdb(_scan_sync, str(persist_directory), per_user)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SpaceDiff:
|
|
missing: Dict[str, Tuple[str, ...]] # per role: ids in source, absent from target
|
|
|
|
@property
|
|
def total_missing(self) -> int:
|
|
return sum(len(ids) for ids in self.missing.values())
|
|
|
|
@property
|
|
def empty(self) -> bool:
|
|
return self.total_missing == 0
|
|
|
|
|
|
def _all_ids(collection) -> List[str]:
|
|
if collection is None:
|
|
return []
|
|
out: List[str] = []
|
|
for got in _iter_pages(collection, [], page=5000):
|
|
out.extend(got.get("ids") or [])
|
|
return out
|
|
|
|
|
|
def _diff_sync(source: VectorSpace, target: VectorSpace, roles: Optional[Iterable[str]]) -> SpaceDiff:
|
|
src_client, tgt_client = _client_for(source), _client_for(target)
|
|
src_names, tgt_names = source.names(), target.names()
|
|
missing: Dict[str, Tuple[str, ...]] = {}
|
|
for role in _roles(source, roles):
|
|
src_ids = _all_ids(_get_collection(src_client, src_names[role]))
|
|
tgt_ids = set(_all_ids(
|
|
_get_collection(tgt_client, tgt_names[role]) if role in tgt_names else None
|
|
))
|
|
missing[role] = tuple(rid for rid in src_ids if rid not in tgt_ids)
|
|
return SpaceDiff(missing=missing)
|
|
|
|
|
|
async def diff_spaces(
|
|
source: SpaceLike, target: SpaceLike, *, roles: Optional[Iterable[str]] = None,
|
|
) -> SpaceDiff:
|
|
"""Per role, the ids the source holds that the target does not.
|
|
|
|
Every record counts whatever its ``status`` (deleted and superseded records
|
|
included). An empty diff means the source holds nothing unmerged.
|
|
"""
|
|
return await _vdb(_diff_sync, _space(source), _space(target), roles)
|
|
|
|
|
|
# ======================================================================
|
|
# Verify and drop
|
|
# ======================================================================
|
|
|
|
@dataclass(frozen=True)
|
|
class GraphVerification:
|
|
ok: bool
|
|
node_counts: Dict[str, Tuple[int, int]] # table: (source, target)
|
|
rel_counts: Dict[str, Tuple[int, int]]
|
|
target_width: Optional[int]
|
|
vector_index: bool
|
|
problems: Tuple[str, ...]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ReembedVerification:
|
|
ok: bool
|
|
per_role: Dict[str, Dict[str, int]] # source, target, missing, extra, mismatched, bad_dims
|
|
per_user: Dict[str, int] # target record count by metadata user_id
|
|
self_recall_hits: int
|
|
self_recall_samples: int
|
|
problems: Tuple[str, ...]
|
|
warnings: Tuple[str, ...] = ()
|
|
graph: Optional[GraphVerification] = None
|
|
|
|
|
|
def _collect(collection, include: Sequence[str], dims: Optional[int]):
|
|
digests: Dict[str, str] = {}
|
|
users: Dict[str, int] = {}
|
|
bad_dims = 0
|
|
if collection is None:
|
|
return digests, users, bad_dims
|
|
for got in _iter_pages(collection, include):
|
|
ids = got.get("ids") or []
|
|
docs = got.get("documents") or [None] * len(ids)
|
|
metas = got.get("metadatas") or [None] * len(ids)
|
|
embeddings = got.get("embeddings")
|
|
for i, rid in enumerate(ids):
|
|
digests[rid] = _record_digest(docs[i], metas[i])
|
|
user = str((metas[i] or {}).get("user_id") or "")
|
|
users[user] = users.get(user, 0) + 1
|
|
if dims is not None and embeddings is not None and len(embeddings[i]) != dims:
|
|
bad_dims += 1
|
|
return digests, users, bad_dims
|
|
|
|
|
|
def _verify_sync(source: VectorSpace, target: VectorSpace, roles: Optional[Iterable[str]]):
|
|
src_client, tgt_client = _client_for(source), _client_for(target)
|
|
src_names, tgt_names = source.names(), target.names()
|
|
per_role: Dict[str, Dict[str, int]] = {}
|
|
per_user_src: Dict[str, int] = {}
|
|
per_user_tgt: Dict[str, int] = {}
|
|
common: List[Tuple[str, str]] = [] # (role, id)
|
|
problems: List[str] = []
|
|
for role in _roles(source, roles):
|
|
if role not in tgt_names:
|
|
continue
|
|
src_digests, src_users, _ = _collect(
|
|
_get_collection(src_client, src_names[role]), ["documents", "metadatas"], None,
|
|
)
|
|
tgt_digests, tgt_users, bad = _collect(
|
|
_get_collection(tgt_client, tgt_names[role]),
|
|
["documents", "metadatas", "embeddings"], int(target.dims or 0) or None,
|
|
)
|
|
missing = [rid for rid in src_digests if rid not in tgt_digests]
|
|
extra = [rid for rid in tgt_digests if rid not in src_digests]
|
|
mismatched = [
|
|
rid for rid, digest in src_digests.items()
|
|
if rid in tgt_digests and tgt_digests[rid] != digest
|
|
]
|
|
per_role[role] = {
|
|
"source": len(src_digests), "target": len(tgt_digests),
|
|
"missing": len(missing), "extra": len(extra),
|
|
"mismatched": len(mismatched), "bad_dims": bad,
|
|
}
|
|
for label, values in (("missing", missing), ("extra", extra), ("mismatched", mismatched)):
|
|
if values:
|
|
problems.append(f"{role}: {len(values)} {label} (e.g. {values[0]})")
|
|
if bad:
|
|
problems.append(f"{role}: {bad} target vectors not {target.dims} wide")
|
|
for user, count in src_users.items():
|
|
per_user_src[user] = per_user_src.get(user, 0) + count
|
|
for user, count in tgt_users.items():
|
|
per_user_tgt[user] = per_user_tgt.get(user, 0) + count
|
|
common.extend((role, rid) for rid in src_digests if rid in tgt_digests)
|
|
for user in sorted(set(per_user_src) | set(per_user_tgt)):
|
|
if per_user_src.get(user, 0) != per_user_tgt.get(user, 0):
|
|
problems.append(
|
|
f"user {user!r}: source {per_user_src.get(user, 0)} != "
|
|
f"target {per_user_tgt.get(user, 0)}"
|
|
)
|
|
return per_role, per_user_tgt, common, problems
|
|
|
|
|
|
def _sample_records(target: VectorSpace, picks: List[Tuple[str, str]]):
|
|
client = _client_for(target)
|
|
names = target.names()
|
|
out = []
|
|
by_role: Dict[str, List[str]] = {}
|
|
for role, rid in picks:
|
|
by_role.setdefault(role, []).append(rid)
|
|
for role, ids in by_role.items():
|
|
collection = _get_collection(client, names[role])
|
|
got = collection.get(ids=ids, include=["documents", "metadatas"])
|
|
for rid, doc, meta in zip(got["ids"], got["documents"], got["metadatas"]):
|
|
out.append((role, rid, reembed_text(role, doc, meta)))
|
|
return out
|
|
|
|
|
|
def _query_sync(target: VectorSpace, role: str, vectors: List[List[float]], k: int) -> List[List[str]]:
|
|
collection = _get_collection(_client_for(target), target.names()[role])
|
|
got = collection.query(query_embeddings=vectors, n_results=k, include=[])
|
|
return [list(ids) for ids in got.get("ids") or []]
|
|
|
|
|
|
async def verify_reembed(
|
|
source: SpaceLike,
|
|
target: SpaceLike,
|
|
*,
|
|
embedding: Optional[EmbeddingService] = None,
|
|
samples: int = 50,
|
|
recall_k: int = 3,
|
|
roles: Optional[Iterable[str]] = None,
|
|
seed: Optional[int] = 0,
|
|
source_graph_path: Optional[Union[str, Path]] = None,
|
|
target_graph_path: Optional[Union[str, Path]] = None,
|
|
) -> ReembedVerification:
|
|
"""Check a finished re-embed.
|
|
|
|
- Per role, the id sets are equal and every id has the same document and
|
|
metadata on both sides (sha256 of the canonical JSON).
|
|
- Every target vector is ``target.dims`` wide.
|
|
- Per ``user_id``, source and target hold the same number of records.
|
|
- Self-recall: for ``samples`` random records, querying the target with
|
|
the re-embedded text returns the record in the top ``recall_k``. Below
|
|
90% this is a warning, not a failure, because near-duplicates exist.
|
|
- With both graph paths: `verify_graph_reembed`.
|
|
"""
|
|
src, tgt = _space(source), _space(target)
|
|
per_role, per_user, common, problems = await _vdb(_verify_sync, src, tgt, roles)
|
|
warnings: List[str] = []
|
|
hits = sampled = 0
|
|
if samples > 0 and common:
|
|
service = embedding
|
|
if service is None and isinstance(target, MemoryConfig):
|
|
service = _default_embedding(target)
|
|
if service is None:
|
|
warnings.append("self-recall skipped: no embedding service")
|
|
else:
|
|
rng = random.Random(seed)
|
|
picks = rng.sample(common, min(samples, len(common)))
|
|
records = await _vdb(_sample_records, tgt, picks)
|
|
by_role: Dict[str, List[Tuple[str, str]]] = {}
|
|
for role, rid, text in records:
|
|
by_role.setdefault(role, []).append((rid, text))
|
|
for role, items in by_role.items():
|
|
vectors = await _embed(service, [_embeddable(text) for _, text in items])
|
|
vectors = _validate_vectors(vectors, len(items), int(tgt.dims), f"verify:{role}")
|
|
found = await _vdb(_query_sync, tgt, role, vectors, recall_k)
|
|
for (rid, _), ids in zip(items, found):
|
|
sampled += 1
|
|
hits += int(rid in ids)
|
|
if sampled and hits / sampled < _SELF_RECALL_WARN_RATE:
|
|
warnings.append(f"self-recall {hits}/{sampled} below {_SELF_RECALL_WARN_RATE:.0%}")
|
|
graph = None
|
|
if source_graph_path is not None and target_graph_path is not None:
|
|
graph = await verify_graph_reembed(source_graph_path, target_graph_path, target_dims=tgt.dims)
|
|
problems.extend(f"graph: {p}" for p in graph.problems)
|
|
return ReembedVerification(
|
|
ok=not problems,
|
|
per_role=per_role,
|
|
per_user=per_user,
|
|
self_recall_hits=hits,
|
|
self_recall_samples=sampled,
|
|
problems=tuple(problems),
|
|
warnings=tuple(warnings),
|
|
graph=graph,
|
|
)
|
|
|
|
|
|
def _drop_sync(space: VectorSpace, roles: Optional[Iterable[str]]) -> List[str]:
|
|
from .data.vector_store_chroma import open_collection_names
|
|
|
|
names = space.names()
|
|
selected = [names[role] for role in _roles(space, roles)]
|
|
busy = sorted(set(selected) & open_collection_names(space.location()))
|
|
if busy:
|
|
raise RuntimeError(
|
|
f"refusing to drop collections held open by a live client: {busy}"
|
|
)
|
|
client = _client_for(space)
|
|
dropped = []
|
|
for name in selected:
|
|
if _get_collection(client, name) is None:
|
|
continue
|
|
client.delete_collection(name=name)
|
|
dropped.append(name)
|
|
return dropped
|
|
|
|
|
|
async def drop_vector_space(
|
|
space: SpaceLike, *, roles: Optional[Iterable[str]] = None,
|
|
) -> List[str]:
|
|
"""Delete the space's collections and return the names deleted.
|
|
|
|
Refuses when a live store in this process holds any of them open.
|
|
"""
|
|
return await _vdb(_drop_sync, _space(space), roles)
|
|
|
|
|
|
# ======================================================================
|
|
# Graph (Kuzu)
|
|
# ======================================================================
|
|
|
|
# All Kuzu work of a re-embed runs on this one thread: Kuzu connections are not
|
|
# thread-safe and a QueryResult must be consumed on the thread that produced it.
|
|
_graph_executor = concurrent.futures.ThreadPoolExecutor(
|
|
max_workers=1, thread_name_prefix="kuzu-reembed",
|
|
)
|
|
_GRAPH_PAGE = 2000
|
|
_GRAPH_WRITE_TX = 1000
|
|
_KUZU_SIDECARS = (".wal", ".shadow", ".lock", ".tmp")
|
|
_SDK_VECTOR_INDEX = (
|
|
"CALL CREATE_VECTOR_INDEX('Memory', 'memory_content_idx', 'embedding', "
|
|
"metric := 'cosine');"
|
|
)
|
|
|
|
# Embedding columns rebuilt from text. Everything else is copied verbatim.
|
|
_GRAPH_EMBEDDING_COLUMNS: Dict[str, Tuple[str, ...]] = {
|
|
"Memory": ("embedding", "beh_embedding"),
|
|
"Topic": ("embedding",),
|
|
"HierarchyTopic": ("embedding",),
|
|
}
|
|
|
|
|
|
async def _graph(func, *args, **kwargs):
|
|
import functools
|
|
return await run_in_executor_guarded(_graph_executor, functools.partial(func, *args, **kwargs))
|
|
|
|
|
|
def hierarchy_topic_label(subject: str, path_json: Any, name: str = "") -> Tuple[str, bool]:
|
|
"""Rebuild the label a HierarchyTopic embedding was computed from.
|
|
|
|
``add_topic_path`` embeds ``"{subject}: kind:segment > kind:segment"`` and
|
|
stores ``path_json`` as ``{"names": [...], "types": [...]}`` for the same
|
|
prefix, so that form rebuilds exactly (returns ``exact=True``). A legacy
|
|
list-form ``path_json`` has no stored types: the label is rebuilt with the
|
|
default Topic -> Facet types the current code would use, and flagged
|
|
inexact. With no usable path the stored ``name`` is used, also inexact.
|
|
"""
|
|
from .structure import parse_hierarchy_path_payload
|
|
|
|
raw = path_json
|
|
if isinstance(raw, str):
|
|
try:
|
|
raw = json.loads(raw)
|
|
except (TypeError, ValueError):
|
|
raw = None
|
|
parsed = parse_hierarchy_path_payload(path_json or "")
|
|
path, types = parsed["path"], parsed["node_types"]
|
|
if not path:
|
|
fallback = str(name or subject or "").strip()
|
|
return (f"{subject}: {fallback}" if subject and name else fallback), False
|
|
exact = isinstance(raw, dict) and isinstance(raw.get("types"), list)
|
|
label = f"{subject}: " + " > ".join(f"{kind}:{segment}" for segment, kind in zip(path, types))
|
|
return label, exact
|
|
|
|
|
|
def _memory_text(row: Mapping[str, Any], column: str) -> str:
|
|
custom = _custom_dict(row.get("custom_json"))
|
|
if column == "beh_embedding":
|
|
return str(custom.get("beh_abstraction") or "").strip()
|
|
text = str(custom.get("retrieval_text") or "").strip()
|
|
return text or str(row.get("content") or "")
|
|
|
|
|
|
def _kuzu():
|
|
try:
|
|
import kuzu
|
|
except ImportError as exc: # pragma: no cover - kuzu is a core dependency
|
|
raise ImportError("kuzu is required. Install with: pip install kuzu") from exc
|
|
return kuzu
|
|
|
|
|
|
def _rows(conn, query: str, params: Optional[Dict[str, Any]] = None) -> List[List[Any]]:
|
|
result = conn.execute(query, params) if params else conn.execute(query)
|
|
out = []
|
|
while result.has_next():
|
|
out.append(list(result.get_next()))
|
|
return out
|
|
|
|
|
|
def _tables(conn) -> Dict[str, str]:
|
|
result = conn.execute("CALL show_tables() RETURN *;")
|
|
columns = result.get_column_names()
|
|
name_i, type_i = columns.index("name"), columns.index("type")
|
|
out = {}
|
|
while result.has_next():
|
|
row = result.get_next()
|
|
out[str(row[name_i])] = str(row[type_i]).upper()
|
|
return out
|
|
|
|
|
|
def _table_columns(conn, table: str) -> List[Tuple[str, str, bool]]:
|
|
"""(name, type, is_primary_key) per property, in declaration order."""
|
|
out = []
|
|
for row in _rows(conn, f"CALL table_info('{table}') RETURN *;"):
|
|
out.append((str(row[1]), str(row[2]), row[4] is True))
|
|
return out
|
|
|
|
|
|
def _array_width(type_name: str) -> Optional[int]:
|
|
match = re.match(r"^FLOAT\[(\d+)\]$", type_name.strip().upper())
|
|
return int(match.group(1)) if match else None
|
|
|
|
|
|
def _count(conn, query: str) -> int:
|
|
rows = _rows(conn, query)
|
|
return int(rows[0][0]) if rows else 0
|
|
|
|
|
|
def _graph_counts(conn) -> Tuple[Dict[str, int], Dict[str, int]]:
|
|
nodes, rels = {}, {}
|
|
for name, kind in _tables(conn).items():
|
|
if kind == "NODE":
|
|
nodes[name] = _count(conn, f"MATCH (n:{name}) RETURN count(n);")
|
|
elif kind == "REL":
|
|
rels[name] = _count(conn, f"MATCH ()-[r:{name}]->() RETURN count(r);")
|
|
return nodes, rels
|
|
|
|
|
|
def _target_ddl(dims: int) -> List[Tuple[str, str]]:
|
|
from .data.graph_store_kuzu import _SCHEMA_DDL_TABLES, _make_memory_ddl
|
|
|
|
out = []
|
|
for name, ddl in _SCHEMA_DDL_TABLES.items():
|
|
out.append((name, _make_memory_ddl(dims) if name == "Memory" else ddl))
|
|
return out
|
|
|
|
|
|
def _open_readonly(path: Path):
|
|
kuzu = _kuzu()
|
|
db = kuzu.Database(str(path), read_only=True)
|
|
return db, kuzu.Connection(db)
|
|
|
|
|
|
def _close(conn, db) -> None:
|
|
for obj in (conn, db):
|
|
if obj is None:
|
|
continue
|
|
try:
|
|
obj.close()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _kuzu_files(path: Path) -> List[Path]:
|
|
out = [path] if path.exists() else []
|
|
for suffix in _KUZU_SIDECARS:
|
|
sidecar = path.with_name(path.name + suffix)
|
|
if sidecar.exists():
|
|
out.append(sidecar)
|
|
return out
|
|
|
|
|
|
def _remove_kuzu(path: Path) -> None:
|
|
for item in _kuzu_files(path):
|
|
if item.is_dir() and not item.is_symlink():
|
|
shutil.rmtree(item)
|
|
else:
|
|
item.unlink()
|
|
|
|
|
|
def _source_width(conn) -> Optional[int]:
|
|
for name, type_name, _ in _table_columns(conn, "Memory"):
|
|
if name == "embedding":
|
|
return _array_width(type_name)
|
|
return None
|
|
|
|
|
|
def _assert_not_open(path: Path, what: str) -> None:
|
|
from .data.graph_store_kuzu import open_graph_paths
|
|
|
|
if os.path.realpath(str(path)) in open_graph_paths():
|
|
raise RuntimeError(f"{what} graph {path} is held open by a live client in this process")
|
|
|
|
|
|
def _plan_graph_sync(path: Path, target_dims: Optional[int]) -> GraphReembedPlan:
|
|
db, conn = _open_readonly(path)
|
|
try:
|
|
nodes, rels = _graph_counts(conn)
|
|
tables = _tables(conn)
|
|
|
|
def _non_null(table: str, column: str) -> int:
|
|
if table not in tables:
|
|
return 0
|
|
return _count(conn, f"MATCH (n:{table}) WHERE n.{column} IS NOT NULL RETURN count(n);")
|
|
|
|
return GraphReembedPlan(
|
|
source_path=str(path),
|
|
node_counts=nodes,
|
|
rel_counts=rels,
|
|
memory_embeddings=_non_null("Memory", "embedding"),
|
|
beh_embeddings=_non_null("Memory", "beh_embedding"),
|
|
topic_embeddings=_non_null("Topic", "embedding"),
|
|
hierarchy_topic_embeddings=_non_null("HierarchyTopic", "embedding"),
|
|
source_width=_source_width(conn) if "Memory" in tables else None,
|
|
target_width=target_dims,
|
|
)
|
|
finally:
|
|
_close(conn, db)
|
|
|
|
|
|
async def plan_graph_reembed(
|
|
source_graph_path: Union[str, Path], *, target_dims: Optional[int] = None,
|
|
) -> GraphReembedPlan:
|
|
"""Node, edge and embedding counts of a graph. Opens it read-only."""
|
|
path = Path(source_graph_path)
|
|
if not path.exists():
|
|
raise FileNotFoundError(f"graph not found: {path}")
|
|
_assert_not_open(path, "source")
|
|
return await _graph(_plan_graph_sync, path, target_dims)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class GraphReembedProgress:
|
|
table: str
|
|
done: int
|
|
total: int
|
|
embedded: int
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class GraphReembedReport:
|
|
target_path: str
|
|
source_width: Optional[int]
|
|
target_width: int
|
|
node_counts: Dict[str, int]
|
|
rel_counts: Dict[str, int]
|
|
embedded: Dict[str, int] # "Table.column": vectors computed
|
|
cleared: Dict[str, int] # "Table.column": vectors set to NULL (no text to rebuild from)
|
|
hierarchy_labels_exact: int
|
|
hierarchy_labels_inexact: Tuple[str, ...] # hierarchy_topic_ids rebuilt from a fallback text
|
|
skipped_tables: Tuple[str, ...] # source tables this SDK does not know
|
|
dropped_columns: Tuple[str, ...] # "Table.column" in the source but not the target
|
|
vector_indexes: Tuple[str, ...]
|
|
elapsed_s: float
|
|
|
|
|
|
@dataclass
|
|
class _GraphCopy:
|
|
source: Path
|
|
staging: Path
|
|
dims: int
|
|
src_db: Any = None
|
|
src: Any = None
|
|
dst_db: Any = None
|
|
dst: Any = None
|
|
node_tables: List[str] = field(default_factory=list)
|
|
rel_tables: List[str] = field(default_factory=list)
|
|
skipped: List[str] = field(default_factory=list)
|
|
dropped: List[str] = field(default_factory=list)
|
|
columns: Dict[str, List[str]] = field(default_factory=dict)
|
|
pk: Dict[str, str] = field(default_factory=dict)
|
|
source_width: Optional[int] = None
|
|
|
|
def open(self) -> None:
|
|
kuzu = _kuzu()
|
|
self.src_db, self.src = _open_readonly(self.source)
|
|
self.staging.parent.mkdir(parents=True, exist_ok=True)
|
|
self.dst_db = kuzu.Database(str(self.staging))
|
|
self.dst = kuzu.Connection(self.dst_db)
|
|
for _, ddl in _target_ddl(self.dims):
|
|
self.dst.execute(ddl + ";")
|
|
src_tables, dst_tables = _tables(self.src), _tables(self.dst)
|
|
self.skipped = sorted(name for name in src_tables if name not in dst_tables)
|
|
for name, kind in dst_tables.items():
|
|
if name not in src_tables:
|
|
continue
|
|
src_cols = _table_columns(self.src, name)
|
|
dst_names = {col for col, _, _ in _table_columns(self.dst, name)}
|
|
self.columns[name] = [col for col, _, _ in src_cols if col in dst_names]
|
|
self.dropped.extend(f"{name}.{col}" for col, _, _ in src_cols if col not in dst_names)
|
|
if kind == "NODE":
|
|
self.node_tables.append(name)
|
|
self.pk[name] = next(col for col, _, is_pk in src_cols if is_pk)
|
|
else:
|
|
self.rel_tables.append(name)
|
|
if "Memory" in src_tables:
|
|
self.source_width = _source_width(self.src)
|
|
|
|
def node_total(self, table: str) -> int:
|
|
return _count(self.src, f"MATCH (n:{table}) RETURN count(n);")
|
|
|
|
def read_nodes(self, table: str, after: Optional[Any], limit: int) -> List[Dict[str, Any]]:
|
|
pk = self.pk[table]
|
|
embed_cols = _GRAPH_EMBEDDING_COLUMNS.get(table, ())
|
|
plain = [c for c in self.columns[table] if c not in embed_cols]
|
|
flags = [c for c in embed_cols if c in self.columns[table]]
|
|
select = [f"n.{c}" for c in plain] + [f"n.{c} IS NOT NULL" for c in flags]
|
|
where = f"WHERE n.{pk} > $after " if after is not None else ""
|
|
query = (
|
|
f"MATCH (n:{table}) {where}RETURN {', '.join(select)} "
|
|
f"ORDER BY n.{pk} LIMIT {int(limit)};"
|
|
)
|
|
params = {"after": after} if after is not None else None
|
|
out = []
|
|
for row in _rows(self.src, query, params):
|
|
record = dict(zip(plain, row[: len(plain)]))
|
|
record["__has"] = {c: bool(v) for c, v in zip(flags, row[len(plain):])}
|
|
out.append(record)
|
|
return out
|
|
|
|
def write_nodes(self, table: str, records: List[Dict[str, Any]]) -> None:
|
|
if not records:
|
|
return
|
|
cols = self.columns[table]
|
|
assigns = ", ".join(f"{c}: $p{i}" for i, c in enumerate(cols))
|
|
query = f"CREATE (n:{table} {{{assigns}}});"
|
|
self.dst.execute("BEGIN TRANSACTION;")
|
|
try:
|
|
for record in records:
|
|
self.dst.execute(query, {f"p{i}": record.get(c) for i, c in enumerate(cols)})
|
|
self.dst.execute("COMMIT;")
|
|
except Exception:
|
|
try:
|
|
self.dst.execute("ROLLBACK;")
|
|
except Exception:
|
|
pass
|
|
raise
|
|
|
|
def copy_rels(self, table: str) -> int:
|
|
props = self.columns[table]
|
|
copied = 0
|
|
for row in _rows(self.src, f"CALL show_connection('{table}') RETURN *;"):
|
|
src_table, dst_table = str(row[0]), str(row[1])
|
|
src_pk, dst_pk = self.pk.get(src_table), self.pk.get(dst_table)
|
|
if src_pk is None or dst_pk is None:
|
|
continue
|
|
select = ", ".join([f"a.{src_pk}", f"b.{dst_pk}"] + [f"r.{p}" for p in props])
|
|
result = self.src.execute(
|
|
f"MATCH (a:{src_table})-[r:{table}]->(b:{dst_table}) RETURN {select};"
|
|
)
|
|
assigns = ", ".join(f"{p}: $p{i}" for i, p in enumerate(props))
|
|
create = (
|
|
f"MATCH (a:{src_table} {{{src_pk}: $from}}), (b:{dst_table} {{{dst_pk}: $to}}) "
|
|
f"CREATE (a)-[:{table} {{{assigns}}}]->(b);"
|
|
)
|
|
pending = 0
|
|
self.dst.execute("BEGIN TRANSACTION;")
|
|
try:
|
|
while result.has_next():
|
|
values = list(result.get_next())
|
|
params = {"from": values[0], "to": values[1]}
|
|
params.update({f"p{i}": v for i, v in enumerate(values[2:])})
|
|
self.dst.execute(create, params)
|
|
copied += 1
|
|
pending += 1
|
|
if pending >= _GRAPH_WRITE_TX:
|
|
self.dst.execute("COMMIT;")
|
|
self.dst.execute("BEGIN TRANSACTION;")
|
|
pending = 0
|
|
self.dst.execute("COMMIT;")
|
|
except Exception:
|
|
try:
|
|
self.dst.execute("ROLLBACK;")
|
|
except Exception:
|
|
pass
|
|
raise
|
|
return copied
|
|
|
|
def create_indexes(self) -> List[str]:
|
|
"""Replay the source's vector indexes, after the data is in."""
|
|
statements: List[str] = []
|
|
try:
|
|
result = self.src.execute("CALL SHOW_INDEXES() RETURN *;")
|
|
columns = result.get_column_names()
|
|
def_i = columns.index("index_definition")
|
|
name_i = columns.index("index_name")
|
|
table_i = columns.index("table_name")
|
|
while result.has_next():
|
|
row = result.get_next()
|
|
definition = str(row[def_i] or "").strip()
|
|
if (
|
|
definition.upper().startswith("CALL CREATE_VECTOR_INDEX(")
|
|
and str(row[table_i]) in self.node_tables
|
|
):
|
|
statements.append((str(row[name_i]), definition))
|
|
except Exception as exc:
|
|
logger.warning("[reembed] could not read source indexes: %s", exc)
|
|
names = [name for name, _ in statements]
|
|
if "memory_content_idx" not in names and "Memory" in self.node_tables:
|
|
statements.append(("memory_content_idx", _SDK_VECTOR_INDEX))
|
|
built = []
|
|
for name, statement in statements:
|
|
self.dst.execute(statement if statement.endswith(";") else statement + ";")
|
|
built.append(name)
|
|
return built
|
|
|
|
def counts(self) -> Tuple[Dict[str, int], Dict[str, int], Dict[str, int], Dict[str, int]]:
|
|
src_nodes, src_rels = _graph_counts(self.src)
|
|
dst_nodes, dst_rels = _graph_counts(self.dst)
|
|
return src_nodes, src_rels, dst_nodes, dst_rels
|
|
|
|
def close(self) -> None:
|
|
_close(self.src, self.src_db)
|
|
_close(self.dst, self.dst_db)
|
|
self.src = self.src_db = self.dst = self.dst_db = None
|
|
|
|
|
|
def _publish(staging: Path, target: Path, overwrite: bool) -> None:
|
|
if _kuzu_files(target):
|
|
if not overwrite:
|
|
raise FileExistsError(f"graph target exists: {target}")
|
|
_remove_kuzu(target)
|
|
for item in _kuzu_files(staging):
|
|
suffix = item.name[len(staging.name):]
|
|
os.replace(item, target.with_name(target.name + suffix))
|
|
|
|
|
|
def _staging_path(target: Path) -> Path:
|
|
return target.with_name(target.name + ".reembed-partial")
|
|
|
|
|
|
async def reembed_graph(
|
|
source_graph_path: Union[str, Path],
|
|
target_graph_path: Union[str, Path],
|
|
*,
|
|
embedding: EmbeddingService,
|
|
target_dims: int,
|
|
batch_size: int = 64,
|
|
cancel: Optional[Callable[[], bool]] = None,
|
|
on_progress: Optional[Callable[[GraphReembedProgress], None]] = None,
|
|
overwrite: bool = False,
|
|
) -> GraphReembedReport:
|
|
"""Build a new Kuzu graph at ``target_graph_path`` with ``target_dims`` vectors.
|
|
|
|
The source is opened read-only and never modified, so it stays the
|
|
rollback copy. The new DB gets the SDK's current DDL at the new width; every
|
|
node and every edge is copied verbatim except the embedding columns, which
|
|
are recomputed with ``embedding``:
|
|
|
|
- ``Memory.embedding``: ``custom.retrieval_text``, else ``content``
|
|
- ``Memory.beh_embedding``: ``custom.beh_abstraction`` (NULL when missing)
|
|
- ``Topic.embedding``: ``name``
|
|
- ``HierarchyTopic.embedding``: the label from `hierarchy_topic_label`
|
|
|
|
A NULL source vector stays NULL. Indexes are built after the data is in.
|
|
The same path is used whether or not the width changes: Kuzu cannot SET an
|
|
indexed column, so an in-place update is not possible anyway.
|
|
|
|
Restart semantics: the copy is built at ``<target>.reembed-partial``, which
|
|
is wiped at every start and on failure or cancel, then moved to the target
|
|
only after node and edge counts match the source. A rerun starts over. An
|
|
existing target raises `FileExistsError` unless ``overwrite=True``.
|
|
"""
|
|
source = Path(source_graph_path)
|
|
target = Path(target_graph_path)
|
|
if os.path.realpath(str(source)) == os.path.realpath(str(target)):
|
|
raise ValueError("source and target graph paths are the same")
|
|
if not source.exists():
|
|
raise FileNotFoundError(f"graph not found: {source}")
|
|
if _kuzu_files(target) and not overwrite:
|
|
raise FileExistsError(f"graph target exists: {target}")
|
|
_assert_not_open(source, "source")
|
|
_assert_not_open(target, "target")
|
|
dims = int(target_dims)
|
|
_check_service_dims(embedding, dims)
|
|
cancelled = cancel or (lambda: False)
|
|
page = max(1, int(batch_size))
|
|
started = time.monotonic()
|
|
staging = _staging_path(target)
|
|
await _graph(_remove_kuzu, staging)
|
|
copy = _GraphCopy(source=source, staging=staging, dims=dims)
|
|
embedded: Dict[str, int] = {}
|
|
cleared: Dict[str, int] = {}
|
|
labels_exact = 0
|
|
labels_inexact: List[str] = []
|
|
published = False
|
|
try:
|
|
await _graph(copy.open)
|
|
for table in copy.node_tables:
|
|
embed_cols = [c for c in _GRAPH_EMBEDDING_COLUMNS.get(table, ()) if c in copy.columns[table]]
|
|
limit = page if embed_cols else _GRAPH_PAGE
|
|
total = await _graph(copy.node_total, table)
|
|
done = computed = 0
|
|
after = None
|
|
while done < total:
|
|
if cancelled():
|
|
raise ReembedCancelled("graph reembed cancelled")
|
|
records = await _graph(copy.read_nodes, table, after, limit)
|
|
if not records:
|
|
break
|
|
after = records[-1][copy.pk[table]]
|
|
for column in embed_cols:
|
|
wanted, texts = [], []
|
|
for record in records:
|
|
record[column] = None
|
|
if not record["__has"].get(column):
|
|
continue
|
|
if table == "Memory":
|
|
text = _memory_text(record, column)
|
|
elif table == "Topic":
|
|
text = str(record.get("name") or "")
|
|
else:
|
|
text, exact = hierarchy_topic_label(
|
|
str(record.get("subject") or ""),
|
|
record.get("path_json"),
|
|
str(record.get("name") or ""),
|
|
)
|
|
if exact:
|
|
labels_exact += 1
|
|
else:
|
|
labels_inexact.append(str(record.get(copy.pk[table])))
|
|
if not text.strip():
|
|
key = f"{table}.{column}"
|
|
cleared[key] = cleared.get(key, 0) + 1
|
|
continue
|
|
wanted.append(record)
|
|
texts.append(text)
|
|
vectors = _validate_vectors(
|
|
await _embed(embedding, texts), len(texts), dims, f"graph:{table}.{column}",
|
|
)
|
|
for record, vector in zip(wanted, vectors):
|
|
record[column] = vector
|
|
if texts:
|
|
key = f"{table}.{column}"
|
|
embedded[key] = embedded.get(key, 0) + len(texts)
|
|
computed += len(texts)
|
|
await _graph(copy.write_nodes, table, records)
|
|
done += len(records)
|
|
if on_progress:
|
|
on_progress(GraphReembedProgress(table=table, done=done, total=total, embedded=computed))
|
|
for table in copy.rel_tables:
|
|
if cancelled():
|
|
raise ReembedCancelled("graph reembed cancelled")
|
|
copied = await _graph(copy.copy_rels, table)
|
|
if on_progress:
|
|
on_progress(GraphReembedProgress(table=table, done=copied, total=copied, embedded=0))
|
|
indexes = await _graph(copy.create_indexes)
|
|
src_nodes, src_rels, dst_nodes, dst_rels = await _graph(copy.counts)
|
|
mismatch = [
|
|
f"{name}: {src_nodes[name]} != {dst_nodes.get(name, 0)}"
|
|
for name in copy.node_tables if src_nodes.get(name, 0) != dst_nodes.get(name, 0)
|
|
] + [
|
|
f"{name}: {src_rels[name]} != {dst_rels.get(name, 0)}"
|
|
for name in copy.rel_tables if src_rels.get(name, 0) != dst_rels.get(name, 0)
|
|
]
|
|
if mismatch:
|
|
raise RuntimeError(f"graph copy count mismatch: {mismatch}")
|
|
await _graph(copy.close)
|
|
await _graph(_publish, staging, target, overwrite)
|
|
published = True
|
|
finally:
|
|
await _graph(copy.close)
|
|
if not published:
|
|
await _graph(_remove_kuzu, staging)
|
|
if labels_inexact:
|
|
logger.warning(
|
|
"[reembed] %d HierarchyTopic labels rebuilt from a fallback text (legacy path_json)",
|
|
len(labels_inexact),
|
|
)
|
|
return GraphReembedReport(
|
|
target_path=str(target),
|
|
source_width=copy.source_width,
|
|
target_width=dims,
|
|
node_counts={name: dst_nodes.get(name, 0) for name in copy.node_tables},
|
|
rel_counts={name: dst_rels.get(name, 0) for name in copy.rel_tables},
|
|
embedded=embedded,
|
|
cleared=cleared,
|
|
hierarchy_labels_exact=labels_exact,
|
|
hierarchy_labels_inexact=tuple(labels_inexact),
|
|
skipped_tables=tuple(copy.skipped),
|
|
dropped_columns=tuple(copy.dropped),
|
|
vector_indexes=tuple(indexes),
|
|
elapsed_s=time.monotonic() - started,
|
|
)
|
|
|
|
|
|
def _verify_graph_sync(source: Path, target: Path, dims: Optional[int]) -> GraphVerification:
|
|
src_db, src = _open_readonly(source)
|
|
try:
|
|
dst_db, dst = _open_readonly(target)
|
|
try:
|
|
src_nodes, src_rels = _graph_counts(src)
|
|
dst_nodes, dst_rels = _graph_counts(dst)
|
|
width = _source_width(dst) if "Memory" in dst_nodes else None
|
|
try:
|
|
index_names = [str(row[1]) for row in _rows(dst, "CALL SHOW_INDEXES() RETURN *;")]
|
|
except Exception:
|
|
index_names = []
|
|
problems: List[str] = []
|
|
node_counts = {}
|
|
for name in sorted(set(src_nodes) | set(dst_nodes)):
|
|
pair = (src_nodes.get(name, 0), dst_nodes.get(name, 0))
|
|
node_counts[name] = pair
|
|
if pair[0] != pair[1]:
|
|
problems.append(f"node table {name}: source {pair[0]} != target {pair[1]}")
|
|
rel_counts = {}
|
|
for name in sorted(set(src_rels) | set(dst_rels)):
|
|
pair = (src_rels.get(name, 0), dst_rels.get(name, 0))
|
|
rel_counts[name] = pair
|
|
if pair[0] != pair[1]:
|
|
problems.append(f"rel table {name}: source {pair[0]} != target {pair[1]}")
|
|
if dims is not None and width != int(dims):
|
|
problems.append(f"Memory.embedding width {width} != {dims}")
|
|
has_index = "memory_content_idx" in index_names
|
|
if "Memory" in dst_nodes and not has_index:
|
|
problems.append("vector index memory_content_idx missing")
|
|
for table, column in (("Memory", "embedding"), ("Topic", "embedding"), ("HierarchyTopic", "embedding")):
|
|
if table not in src_nodes or table not in dst_nodes:
|
|
continue
|
|
query = f"MATCH (n:{table}) WHERE n.{column} IS NOT NULL RETURN count(n);"
|
|
before, after = _count(src, query), _count(dst, query)
|
|
if before != after:
|
|
problems.append(f"{table}.{column}: {before} vectors in source, {after} in target")
|
|
return GraphVerification(
|
|
ok=not problems,
|
|
node_counts=node_counts,
|
|
rel_counts=rel_counts,
|
|
target_width=width,
|
|
vector_index=has_index,
|
|
problems=tuple(problems),
|
|
)
|
|
finally:
|
|
_close(dst, dst_db)
|
|
finally:
|
|
_close(src, src_db)
|
|
|
|
|
|
async def verify_graph_reembed(
|
|
source_graph_path: Union[str, Path],
|
|
target_graph_path: Union[str, Path],
|
|
*,
|
|
target_dims: Optional[int] = None,
|
|
) -> GraphVerification:
|
|
"""Node and edge counts per table, target width, vector index, vector counts."""
|
|
source, target = Path(source_graph_path), Path(target_graph_path)
|
|
_assert_not_open(source, "source")
|
|
_assert_not_open(target, "target")
|
|
return await _graph(_verify_graph_sync, source, target, target_dims)
|
|
|
|
|
|
async def drop_graph(graph_path: Union[str, Path]) -> bool:
|
|
"""Delete a Kuzu graph (and its sidecar files). Returns False if absent.
|
|
|
|
Refuses when a live store in this process holds it open.
|
|
"""
|
|
path = Path(graph_path)
|
|
_assert_not_open(path, "the")
|
|
if not _kuzu_files(path):
|
|
return False
|
|
await _graph(_remove_kuzu, path)
|
|
return True
|
|
|
|
|
|
__all__ = [
|
|
"VECTOR_ROLES",
|
|
"VectorRole",
|
|
"VectorSpace",
|
|
"EmbeddingSpaceIdentity",
|
|
"space_identity",
|
|
"vector_space_names",
|
|
"reembed_text",
|
|
"ReembedCancelled",
|
|
"ReembedCollectionPlan",
|
|
"ReembedPlan",
|
|
"plan_reembed",
|
|
"ReembedProgress",
|
|
"ReembedReport",
|
|
"reembed_vector_space",
|
|
"ScannedCollection",
|
|
"ScannedSpace",
|
|
"scan_vector_spaces",
|
|
"parse_collection_name",
|
|
"SpaceDiff",
|
|
"diff_spaces",
|
|
"ReembedVerification",
|
|
"verify_reembed",
|
|
"drop_vector_space",
|
|
"GraphReembedPlan",
|
|
"plan_graph_reembed",
|
|
"GraphReembedProgress",
|
|
"GraphReembedReport",
|
|
"reembed_graph",
|
|
"hierarchy_topic_label",
|
|
"GraphVerification",
|
|
"verify_graph_reembed",
|
|
"drop_graph",
|
|
]
|