Files
2026-07-13 12:08:54 +08:00

1458 lines
62 KiB
Python

import asyncio
import configparser
import hashlib
import json
import os
import uuid
from dataclasses import dataclass
from typing import Any, List, final
import numpy as np
import pipmaster as pm
from ..base import BaseVectorStorage
from ..constants import DEFAULT_QUERY_PRIORITY
from ..exceptions import DataMigrationError
from ..kg.shared_storage import get_data_init_lock, get_namespace_lock
from ..utils import _cooperative_yield, compute_mdhash_id, logger, validate_workspace
if not pm.is_installed("qdrant-client"):
pm.install("qdrant-client")
from qdrant_client import QdrantClient, models # type: ignore
@dataclass
class _PendingVectorDoc:
"""Buffered vector upsert waiting for embedding and/or bulk flush."""
source: dict[str, Any]
content: str
vector: list[float] | None = None
DEFAULT_WORKSPACE = "_"
WORKSPACE_ID_FIELD = "workspace_id"
ENTITY_PREFIX = "ent-"
CREATED_AT_FIELD = "created_at"
ID_FIELD = "id"
DEFAULT_QDRANT_UPSERT_MAX_PAYLOAD_BYTES = 16 * 1024 * 1024 # 16MB
DEFAULT_QDRANT_UPSERT_MAX_POINTS_PER_BATCH = 128
DEFAULT_QDRANT_DELETE_MAX_POINTS_PER_BATCH = 1000
config = configparser.ConfigParser()
config.read("config.ini", "utf-8")
def compute_mdhash_id_for_qdrant(
content: str, prefix: str = "", style: str = "simple"
) -> str:
"""
Generate a UUID based on the content and support multiple formats.
:param content: The content used to generate the UUID.
:param style: The format of the UUID, optional values are "simple", "hyphenated", "urn".
:return: A UUID that meets the requirements of Qdrant.
"""
if not content:
raise ValueError("Content must not be empty.")
# Use the hash value of the content to create a UUID.
hashed_content = hashlib.sha256((prefix + content).encode("utf-8")).digest()
generated_uuid = uuid.UUID(bytes=hashed_content[:16], version=4)
# Return the UUID according to the specified format.
if style == "simple":
return generated_uuid.hex
elif style == "hyphenated":
return str(generated_uuid)
elif style == "urn":
return f"urn:uuid:{generated_uuid}"
else:
raise ValueError("Invalid style. Choose from 'simple', 'hyphenated', or 'urn'.")
def workspace_filter_condition(workspace: str) -> models.FieldCondition:
"""
Create a workspace filter condition for Qdrant queries.
"""
return models.FieldCondition(
key=WORKSPACE_ID_FIELD, match=models.MatchValue(value=workspace)
)
def _find_legacy_collection(
client: QdrantClient,
namespace: str,
workspace: str = None,
model_suffix: str = None,
) -> str | None:
"""
Find legacy collection with backward compatibility support.
This function tries multiple naming patterns to locate legacy collections
created by older versions of LightRAG:
1. lightrag_vdb_{namespace} - if model_suffix is provided (HIGHEST PRIORITY)
2. {workspace}_{namespace} or {namespace} - no matter if model_suffix is provided or not
3. lightrag_vdb_{namespace} - fall back value no matter if model_suffix is provided or not (LOWEST PRIORITY)
Args:
client: QdrantClient instance
namespace: Base namespace (e.g., "chunks", "entities")
workspace: Optional workspace identifier
model_suffix: Optional model suffix for new collection
Returns:
Collection name if found, None otherwise
"""
# Try multiple naming patterns for backward compatibility
# More specific names (with workspace) have higher priority
candidates = [
f"lightrag_vdb_{namespace}" if model_suffix else None,
f"{workspace}_{namespace}" if workspace else None,
f"lightrag_vdb_{namespace}",
namespace,
]
for candidate in candidates:
if candidate and client.collection_exists(candidate):
logger.info(
f"Qdrant: Found legacy collection '{candidate}' "
f"(namespace={namespace}, workspace={workspace or 'none'})"
)
return candidate
return None
def _legacy_collection_has_workspace_field(
client: QdrantClient, collection_name: str
) -> bool | None:
"""Return whether the legacy collection tags its points with workspace_id.
Mirrors the detection in ``setup_collection``: trust the payload schema for
indexed fields, otherwise sample a few points (payload_schema only reflects
INDEXED fields).
Returns:
``True`` - workspace_id present (workspace-tagged legacy).
``False`` - confidently absent (untagged, pre-isolation legacy):
``setup_collection`` migrates ALL of it with no workspace
filter, so the whole collection is the migration source.
``None`` - tagging could not be determined (metadata / scroll error).
Callers MUST NOT treat this as untagged: dropping an
actually-tagged, shared legacy collection would delete other
workspaces' migration source.
"""
try:
legacy_info = client.get_collection(collection_name)
if WORKSPACE_ID_FIELD in (legacy_info.payload_schema or {}):
return True
sample_points, _ = client.scroll(
collection_name=collection_name,
limit=10,
with_payload=True,
with_vectors=False,
)
except Exception as e:
logger.warning(
f"Qdrant: could not determine workspace tagging of legacy collection "
f"'{collection_name}': {e}"
)
return None
return any(
point.payload and WORKSPACE_ID_FIELD in point.payload for point in sample_points
)
@final
@dataclass
class QdrantVectorDBStorage(BaseVectorStorage):
def __init__(
self, namespace, global_config, embedding_func, workspace=None, meta_fields=None
):
super().__init__(
namespace=namespace,
workspace=workspace or "",
global_config=global_config,
embedding_func=embedding_func,
meta_fields=meta_fields or set(),
)
self.__post_init__()
@staticmethod
def setup_collection(
client: QdrantClient,
collection_name: str,
namespace: str,
workspace: str,
vectors_config: models.VectorParams,
hnsw_config: models.HnswConfigDiff,
model_suffix: str,
):
"""
Setup Qdrant collection with migration support from legacy collections.
Ensure final collection has workspace isolation index.
Check vector dimension compatibility before new collection creation.
Drop legacy collection if it exists and is empty.
Only migrate data from legacy collection to new collection when new collection first created and legacy collection is not empty.
Args:
client: QdrantClient instance
collection_name: Name of the final collection
namespace: Base namespace (e.g., "chunks", "entities")
workspace: Workspace identifier for data isolation
vectors_config: Vector configuration parameters for the collection
hnsw_config: HNSW index configuration diff for the collection
"""
if not namespace or not workspace:
raise ValueError("namespace and workspace must be provided")
workspace_count_filter = models.Filter(
must=[workspace_filter_condition(workspace)]
)
new_collection_exists = client.collection_exists(collection_name)
legacy_collection = _find_legacy_collection(
client, namespace, workspace, model_suffix
)
# Case 1: Only new collection exists or new collection is the same as legacy collection
# No data migration needed, and ensuring index is created then return
if (new_collection_exists and not legacy_collection) or (
collection_name == legacy_collection
):
# create_payload_index return without error if index already exists
client.create_payload_index(
collection_name=collection_name,
field_name=WORKSPACE_ID_FIELD,
field_schema=models.KeywordIndexParams(
type=models.KeywordIndexType.KEYWORD,
is_tenant=True,
),
)
new_workspace_count = client.count(
collection_name=collection_name,
count_filter=workspace_count_filter,
exact=True,
).count
# Skip data migration if new collection already has workspace data
if new_workspace_count == 0 and not (collection_name == legacy_collection):
logger.warning(
f"Qdrant: workspace data in collection '{collection_name}' is empty. "
f"Ensure it is caused by new workspace setup and not an unexpected embedding model change."
)
return
legacy_count = None
if not new_collection_exists:
# Check vector dimension compatibility before creating new collection
if legacy_collection:
legacy_count = client.count(
collection_name=legacy_collection, exact=True
).count
if legacy_count > 0:
legacy_info = client.get_collection(legacy_collection)
legacy_dim = legacy_info.config.params.vectors.size
if vectors_config.size and legacy_dim != vectors_config.size:
logger.error(
f"Qdrant: Dimension mismatch detected! "
f"Legacy collection '{legacy_collection}' has {legacy_dim}d vectors, "
f"but new embedding model expects {vectors_config.size}d."
)
raise DataMigrationError(
f"Dimension mismatch between legacy collection '{legacy_collection}' "
f"and new collection. Expected {vectors_config.size}d but got {legacy_dim}d."
)
client.create_collection(
collection_name, vectors_config=vectors_config, hnsw_config=hnsw_config
)
logger.info(f"Qdrant: Collection '{collection_name}' created successfully")
if not legacy_collection:
logger.warning(
"Qdrant: Ensure this new collection creation is caused by new workspace setup and not an unexpected embedding model change."
)
# create_payload_index return without error if index already exists
client.create_payload_index(
collection_name=collection_name,
field_name=WORKSPACE_ID_FIELD,
field_schema=models.KeywordIndexParams(
type=models.KeywordIndexType.KEYWORD,
is_tenant=True,
),
)
# Case 2: Legacy collection exist
if legacy_collection:
# Only drop legacy collection if it's empty
if legacy_count is None:
legacy_count = client.count(
collection_name=legacy_collection, exact=True
).count
if legacy_count == 0:
client.delete_collection(collection_name=legacy_collection)
logger.info(
f"Qdrant: Empty legacy collection '{legacy_collection}' deleted successfully"
)
return
new_workspace_count = client.count(
collection_name=collection_name,
count_filter=workspace_count_filter,
exact=True,
).count
# Skip data migration if new collection already has workspace data
if new_workspace_count > 0:
logger.warning(
f"Qdrant: Both new and legacy collection have data. "
f"{legacy_count} records in {legacy_collection} require manual deletion after migration verification."
)
return
# Case 3: Only legacy exists - migrate data from legacy collection to new collection
# Check if legacy collection has workspace_id to determine migration strategy
# Note: payload_schema only reflects INDEXED fields, so we also sample
# actual payloads to detect unindexed workspace_id fields
legacy_info = client.get_collection(legacy_collection)
has_workspace_index = WORKSPACE_ID_FIELD in (
legacy_info.payload_schema or {}
)
# Detect workspace_id field presence by sampling payloads if not indexed
# This prevents cross-workspace data leakage when workspace_id exists but isn't indexed
has_workspace_field = has_workspace_index
if not has_workspace_index:
# Sample a small batch of points to check for workspace_id in payloads
# All points must have workspace_id if any point has it
sample_result = client.scroll(
collection_name=legacy_collection,
limit=10, # Small sample is sufficient for detection
with_payload=True,
with_vectors=False,
)
sample_points, _ = sample_result
for point in sample_points:
if point.payload and WORKSPACE_ID_FIELD in point.payload:
has_workspace_field = True
logger.info(
f"Qdrant: Detected unindexed {WORKSPACE_ID_FIELD} field "
f"in legacy collection '{legacy_collection}' via payload sampling"
)
break
# Build workspace filter if legacy collection has workspace support
# This prevents cross-workspace data leakage during migration
legacy_scroll_filter = None
if has_workspace_field:
legacy_scroll_filter = models.Filter(
must=[workspace_filter_condition(workspace)]
)
# Recount with workspace filter for accurate migration tracking
legacy_count = client.count(
collection_name=legacy_collection,
count_filter=legacy_scroll_filter,
exact=True,
).count
logger.info(
f"Qdrant: Legacy collection has workspace support, "
f"filtering to {legacy_count} records for workspace '{workspace}'"
)
logger.info(
f"Qdrant: Found legacy collection '{legacy_collection}' with {legacy_count} records to migrate."
)
logger.info(
f"Qdrant: Migrating data from legacy collection '{legacy_collection}' to new collection '{collection_name}'"
)
try:
# Batch migration (500 records per batch)
migrated_count = 0
offset = None
batch_size = 500
while True:
# Scroll through legacy data with optional workspace filter
result = client.scroll(
collection_name=legacy_collection,
scroll_filter=legacy_scroll_filter,
limit=batch_size,
offset=offset,
with_vectors=True,
with_payload=True,
)
points, next_offset = result
if not points:
break
# Transform points for new collection
new_points = []
for point in points:
# Set workspace_id in payload
new_payload = dict(point.payload or {})
new_payload[WORKSPACE_ID_FIELD] = workspace
# Create new point with workspace-prefixed ID
original_id = new_payload.get(ID_FIELD)
if original_id:
new_point_id = compute_mdhash_id_for_qdrant(
original_id, prefix=workspace
)
else:
# Fallback: use original point ID
new_point_id = str(point.id)
new_points.append(
models.PointStruct(
id=new_point_id,
vector=point.vector,
payload=new_payload,
)
)
# Upsert to new collection
client.upsert(
collection_name=collection_name, points=new_points, wait=True
)
migrated_count += len(points)
logger.info(
f"Qdrant: {migrated_count}/{legacy_count} records migrated"
)
# Check if we've reached the end
if next_offset is None:
break
offset = next_offset
new_count_after = client.count(
collection_name=collection_name,
count_filter=workspace_count_filter,
exact=True,
).count
inserted_count = new_count_after - new_workspace_count
if inserted_count != legacy_count:
error_msg = (
"Qdrant: Migration verification failed, expected "
f"{legacy_count} inserted records, got {inserted_count}."
)
logger.error(error_msg)
raise DataMigrationError(error_msg)
except DataMigrationError:
# Re-raise DataMigrationError as-is to preserve specific error messages
raise
except Exception as e:
logger.error(
f"Qdrant: Failed to migrate data from legacy collection '{legacy_collection}' to new collection '{collection_name}': {e}"
)
raise DataMigrationError(
f"Failed to migrate data from legacy collection '{legacy_collection}' to new collection '{collection_name}'"
) from e
logger.info(
f"Qdrant: Migration from '{legacy_collection}' to '{collection_name}' completed successfully"
)
logger.warning(
"Qdrant: Manual deletion is required after data migration verification."
)
def __post_init__(self):
validate_workspace(self.workspace)
self._validate_embedding_func()
# Check for QDRANT_WORKSPACE environment variable first (higher priority)
# This allows administrators to force a specific workspace for all Qdrant storage instances
qdrant_workspace = os.environ.get("QDRANT_WORKSPACE")
if qdrant_workspace and qdrant_workspace.strip():
# Use environment variable value, overriding the passed workspace parameter
effective_workspace = qdrant_workspace.strip()
logger.info(
f"Using QDRANT_WORKSPACE environment variable: '{effective_workspace}' (overriding '{self.workspace}/{self.namespace}')"
)
else:
# Use the workspace parameter passed during initialization
effective_workspace = self.workspace
if effective_workspace:
logger.debug(
f"Using passed workspace parameter: '{effective_workspace}'"
)
self.effective_workspace = effective_workspace or DEFAULT_WORKSPACE
# Generate model suffix
self.model_suffix = self._generate_collection_suffix()
# New naming scheme with model isolation
# Example: "lightrag_vdb_chunks_text_embedding_ada_002_1536d"
# Ensure model_suffix is not empty before appending
if self.model_suffix:
self.final_namespace = f"lightrag_vdb_{self.namespace}_{self.model_suffix}"
logger.info(f"Qdrant collection: {self.final_namespace}")
else:
# Fallback: use legacy namespace if model_suffix is unavailable
self.final_namespace = f"lightrag_vdb_{self.namespace}"
logger.warning(
f"Qdrant collection: {self.final_namespace} missing suffix. Pls add model_name to embedding_func for proper workspace data isolation."
)
kwargs = self.global_config.get("vector_db_storage_cls_kwargs", {})
cosine_threshold = kwargs.get("cosine_better_than_threshold")
if cosine_threshold is None:
raise ValueError(
"cosine_better_than_threshold must be specified in vector_db_storage_cls_kwargs"
)
self.cosine_better_than_threshold = cosine_threshold
# Initialize client as None - will be created in initialize() method
self._client = None
self._max_batch_size = self.global_config["embedding_batch_num"]
self._max_upsert_payload_bytes = int(
os.getenv(
"QDRANT_UPSERT_MAX_PAYLOAD_BYTES",
str(DEFAULT_QDRANT_UPSERT_MAX_PAYLOAD_BYTES),
)
)
self._max_upsert_points_per_batch = int(
os.getenv(
"QDRANT_UPSERT_MAX_POINTS_PER_BATCH",
str(DEFAULT_QDRANT_UPSERT_MAX_POINTS_PER_BATCH),
)
)
self._max_delete_points_per_batch = int(
os.getenv(
"QDRANT_DELETE_MAX_POINTS_PER_BATCH",
str(DEFAULT_QDRANT_DELETE_MAX_POINTS_PER_BATCH),
)
)
if self._max_upsert_payload_bytes <= 0:
logger.warning(
f"QDRANT_UPSERT_MAX_PAYLOAD_BYTES={self._max_upsert_payload_bytes} is non-positive, disable payload-size splitting"
)
if self._max_upsert_points_per_batch <= 0:
logger.warning(
f"QDRANT_UPSERT_MAX_POINTS_PER_BATCH={self._max_upsert_points_per_batch} is non-positive, disable point-count splitting"
)
if self._max_delete_points_per_batch <= 0:
logger.warning(
f"QDRANT_DELETE_MAX_POINTS_PER_BATCH={self._max_delete_points_per_batch} is non-positive, disable delete point-count splitting"
)
self._initialized = False
# Deferred-embedding buffers and the per-namespace flush lock.
# Qdrant partitions a single physical collection across workspaces
# via the workspace_id payload field, so the lock must include the
# effective workspace (not just final_namespace) to avoid letting
# two effectively-different writers race on the same collection.
self._pending_vector_docs: dict[str, _PendingVectorDoc] = {}
self._pending_vector_deletes: set[str] = set()
self._flush_lock = None
@staticmethod
def _to_json_serializable(value: Any) -> Any:
"""Convert nested values to JSON-serializable types for payload size estimation."""
if isinstance(value, np.ndarray):
return value.tolist()
if isinstance(value, np.integer):
return int(value)
if isinstance(value, np.floating):
return float(value)
if isinstance(value, dict):
return {
str(k): QdrantVectorDBStorage._to_json_serializable(v)
for k, v in value.items()
}
if isinstance(value, (list, tuple)):
return [QdrantVectorDBStorage._to_json_serializable(v) for v in value]
return value
@staticmethod
def _estimate_point_payload_bytes(point: models.PointStruct) -> int:
"""Estimate serialized JSON byte size of a single Qdrant point."""
point_obj = {
"id": point.id,
"vector": QdrantVectorDBStorage._to_json_serializable(point.vector),
"payload": QdrantVectorDBStorage._to_json_serializable(point.payload or {}),
}
return len(
json.dumps(
point_obj,
ensure_ascii=False,
separators=(",", ":"),
).encode("utf-8")
)
@staticmethod
def _build_upsert_batches(
points: list[models.PointStruct],
max_payload_bytes: int,
max_points_per_batch: int,
) -> list[tuple[list[models.PointStruct], int]]:
"""Split points into batches using payload size and point count limits.
The byte budget is the primary limiter; the point count is a secondary
guard. A single point larger than the byte budget is emitted as its own
single-point batch rather than raising: the JSON estimate is
conservative (and the default budget sits well below the real
server/gateway limit), so the request may still be accepted. Leaving the
server as the final arbiter avoids failing the entire flush over one
oversized point, which would also block every healthy point buffered
alongside it from ever committing.
"""
if not points:
return []
payload_limit = max_payload_bytes if max_payload_bytes > 0 else float("inf")
points_limit = (
max_points_per_batch if max_points_per_batch > 0 else float("inf")
)
batches: list[tuple[list[models.PointStruct], int]] = []
current_batch: list[models.PointStruct] = []
# JSON array overhead ("[]")
current_estimated_bytes = 2
for point in points:
point_size = QdrantVectorDBStorage._estimate_point_payload_bytes(point)
# If current batch not empty, a comma is needed before next element.
separator_overhead = 1 if current_batch else 0
next_batch_size = current_estimated_bytes + separator_overhead + point_size
if current_batch and (
len(current_batch) >= points_limit or next_batch_size > payload_limit
):
batches.append((current_batch, current_estimated_bytes))
current_batch = []
current_estimated_bytes = 2
next_batch_size = current_estimated_bytes + point_size
current_batch.append(point)
current_estimated_bytes = next_batch_size
if current_batch:
batches.append((current_batch, current_estimated_bytes))
return batches
async def initialize(self):
"""Initialize Qdrant collection"""
async with get_data_init_lock():
if self._initialized:
return
try:
# Create QdrantClient if not already created
if self._client is None:
self._client = QdrantClient(
url=os.environ.get(
"QDRANT_URL", config.get("qdrant", "uri", fallback=None)
),
api_key=os.environ.get(
"QDRANT_API_KEY",
config.get("qdrant", "apikey", fallback=None),
),
)
logger.debug(
f"[{self.workspace}] QdrantClient created successfully"
)
# Setup collection (create if not exists and configure indexes)
# Pass namespace and workspace for backward-compatible migration support
QdrantVectorDBStorage.setup_collection(
self._client,
self.final_namespace,
namespace=self.namespace,
workspace=self.effective_workspace,
vectors_config=models.VectorParams(
size=self.embedding_func.embedding_dim,
distance=models.Distance.COSINE,
),
hnsw_config=models.HnswConfigDiff(
payload_m=16,
m=0,
),
model_suffix=self.model_suffix,
)
# Removed duplicate max batch size initialization
self._initialized = True
logger.info(
f"[{self.workspace}] Qdrant collection '{self.namespace}' initialized successfully"
)
except Exception as e:
logger.error(
f"[{self.workspace}] Failed to initialize Qdrant collection '{self.namespace}': {e}"
)
raise
if self._flush_lock is None:
self._flush_lock = get_namespace_lock(
namespace=self.final_namespace,
workspace=self.effective_workspace,
)
async def upsert(self, data: dict[str, dict[str, Any]]) -> None:
"""Buffer vector docs for embedding and batched flush.
Embedding deliberately does NOT happen here: repeated upserts of
the same id, or many small batches, collapse into a single
flush-time embedding pass. The buffer is keyed by the caller's
original doc id; the Qdrant UUID conversion runs at flush time.
"""
if not data:
return
import time
current_time = int(time.time())
pending_docs: list[tuple[str, _PendingVectorDoc]] = []
for i, (k, v) in enumerate(data.items(), start=1):
source = {
ID_FIELD: k,
WORKSPACE_ID_FIELD: self.effective_workspace,
CREATED_AT_FIELD: current_time,
**{k1: v1 for k1, v1 in v.items() if k1 in self.meta_fields},
}
pending_docs.append(
(
k,
_PendingVectorDoc(source=source, content=v["content"]),
)
)
await _cooperative_yield(i)
# An upsert overrides any pending delete on the same id; installing
# a fresh _PendingVectorDoc invalidates any vector cached by a
# prior get_vectors_by_ids() call on a stale revision.
async with self._flush_lock:
for doc_id, pdoc in pending_docs:
self._pending_vector_deletes.discard(doc_id)
self._pending_vector_docs[doc_id] = pdoc
async def query(
self, query: str, top_k: int, query_embedding: list[float] = None
) -> list[dict[str, Any]]:
"""Query the vector database via Qdrant ``query_points``.
Reads from the server-side index only; buffered upserts and deletes
are NOT visible until ``index_done_callback`` / ``finalize`` flushes
them. Callers that need read-your-writes for a freshly upserted id
should use ``get_by_id`` / ``get_by_ids`` (which consult the buffer)
or flush first. Matches the deferred-embedding contract used by the
other lazy-embedding backends (Mongo / OpenSearch / FAISS / Nano).
"""
if query_embedding is not None:
embedding = query_embedding
else:
embedding_result = await self.embedding_func(
[query], context="query", _priority=DEFAULT_QUERY_PRIORITY
) # higher priority for query
embedding = embedding_result[0]
results = self._client.query_points(
collection_name=self.final_namespace,
query=embedding,
limit=top_k,
with_payload=True,
score_threshold=self.cosine_better_than_threshold,
query_filter=models.Filter(
must=[workspace_filter_condition(self.effective_workspace)]
),
).points
return [
{
**dp.payload,
"distance": dp.score,
CREATED_AT_FIELD: dp.payload.get(CREATED_AT_FIELD),
}
for dp in results
]
async def index_done_callback(self) -> None:
"""Flush buffered vector ops; Qdrant persists automatically once written."""
await self._flush_pending_vector_ops()
async def drop_pending_index_ops(self) -> None:
"""Discard buffered upserts/deletes (pipeline aborting on error)."""
async with self._flush_lock:
self._pending_vector_docs.clear()
self._pending_vector_deletes.clear()
async def _flush_pending_vector_ops(self) -> None:
"""Flush buffered vector upserts and deletes via batched client calls.
Embedding runs *inside* this lock (not in `upsert` or lock-free):
it makes deferred embedding and the upsert atomic against
concurrent upserts and destructive mutations. Reuses
``_build_upsert_batches`` to respect Qdrant's payload size limit.
Any failure (embed or server write) raises and leaves both
buffers intact; the next ``index_done_callback`` retries.
Concurrency invariant: ``_flush_lock`` is a non-reentrant asyncio
lock. Callers MUST NOT hold it when invoking this method --
re-entry would deadlock. The only in-tree callers are
``index_done_callback`` and ``finalize``, both lock-free.
"""
async with self._flush_lock:
if not self._pending_vector_docs and not self._pending_vector_deletes:
return
if self._client is None:
return
pending_docs = self._pending_vector_docs
pending_deletes = self._pending_vector_deletes
docs_to_embed: list[tuple[str, _PendingVectorDoc]] = [
(doc_id, pdoc)
for doc_id, pdoc in pending_docs.items()
if pdoc.vector is None
]
if docs_to_embed:
contents = [pdoc.content for _, pdoc in docs_to_embed]
batches = [
contents[i : i + self._max_batch_size]
for i in range(0, len(contents), self._max_batch_size)
]
logger.info(
f"[{self.workspace}] {self.namespace} flush: embedding "
f"{len(docs_to_embed)} vectors in {len(batches)} batch(es) "
f"(batch_num={self._max_batch_size})"
)
try:
embeddings_list = await asyncio.gather(
*[
self.embedding_func(batch, context="document")
for batch in batches
]
)
except Exception as e:
logger.error(
f"[{self.workspace}] Error embedding pending vector ops "
f"(upserts={len(docs_to_embed)}): {e}"
)
raise
embeddings = np.concatenate(embeddings_list)
if len(embeddings) != len(docs_to_embed):
raise RuntimeError(
f"[{self.workspace}] Embedding count mismatch: expected "
f"{len(docs_to_embed)}, got {len(embeddings)}"
)
for i, ((_, pdoc), embedding) in enumerate(
zip(docs_to_embed, embeddings), start=1
):
# Cache the raw numpy row so a second flush after a
# server-side error doesn't re-embed.
pdoc.vector = np.array(embedding, dtype=np.float32).tolist()
await _cooperative_yield(i)
# Build PointStruct list, converting caller-supplied ids to
# Qdrant UUIDs only now (the buffer keeps caller ids so
# read-your-writes works against the same key).
list_points: list[models.PointStruct] = []
committed_ids: list[str] = []
for doc_id, pdoc in pending_docs.items():
if pdoc.vector is None:
continue
committed_ids.append(doc_id)
list_points.append(
models.PointStruct(
id=compute_mdhash_id_for_qdrant(
doc_id, prefix=self.effective_workspace
),
vector=pdoc.vector,
payload=dict(pdoc.source),
)
)
try:
if list_points:
point_batches = self._build_upsert_batches(
list_points,
max_payload_bytes=self._max_upsert_payload_bytes,
max_points_per_batch=self._max_upsert_points_per_batch,
)
if len(point_batches) > 1:
logger.info(
f"[{self.workspace}] Qdrant upsert split into {len(point_batches)} batches "
f"for {len(list_points)} records (max_payload={self._max_upsert_payload_bytes}, "
f"batch={self._max_upsert_points_per_batch})"
)
for batch_index, (points_batch, estimated_bytes) in enumerate(
point_batches, 1
):
if (
len(points_batch) == 1
and self._max_upsert_payload_bytes > 0
and estimated_bytes > self._max_upsert_payload_bytes
):
logger.warning(
f"[{self.workspace}] {self.namespace} flush: single point "
f"id={points_batch[0].id} estimated {estimated_bytes} bytes "
f"exceeds QDRANT_UPSERT_MAX_PAYLOAD_BYTES="
f"{self._max_upsert_payload_bytes}; sending as its own batch"
)
logger.debug(
f"[{self.workspace}] Qdrant upsert batch {batch_index}/{len(point_batches)}: "
f"points={len(points_batch)}, estimated_payload_bytes={estimated_bytes}"
)
# Fail-fast: any batch failure raises immediately
# and stops subsequent batches; the full buffer is
# retained so the next flush retries.
self._client.upsert(
collection_name=self.final_namespace,
points=points_batch,
wait=True,
)
if pending_deletes:
qdrant_delete_ids = [
compute_mdhash_id_for_qdrant(
doc_id, prefix=self.effective_workspace
)
for doc_id in pending_deletes
]
# Chunk deletes by point count; ids are short so a count cap
# is enough to keep each request under the server limit.
delete_chunk = (
self._max_delete_points_per_batch
if self._max_delete_points_per_batch > 0
else len(qdrant_delete_ids)
)
for i in range(0, len(qdrant_delete_ids), delete_chunk):
self._client.delete(
collection_name=self.final_namespace,
points_selector=models.PointIdsList(
points=qdrant_delete_ids[i : i + delete_chunk]
),
wait=True,
)
except Exception as e:
logger.error(
f"[{self.workspace}] Error flushing vector ops "
f"(upserts={len(pending_docs)}, "
f"deletes={len(pending_deletes)}): {e}"
)
raise
for doc_id in committed_ids:
pending_docs.pop(doc_id, None)
pending_deletes.clear()
async def delete(self, ids: List[str]) -> None:
"""Buffer vector deletes for batched flush."""
if not ids:
return
if isinstance(ids, set):
ids = list(ids)
async with self._flush_lock:
for doc_id in ids:
self._pending_vector_docs.pop(doc_id, None)
self._pending_vector_deletes.add(doc_id)
logger.debug(
f"[{self.workspace}] Buffered delete for {len(ids)} vectors in {self.namespace}"
)
async def delete_entity(self, entity_name: str) -> None:
"""Buffer an entity vector delete by computing its hash ID."""
entity_id = compute_mdhash_id(entity_name, prefix=ENTITY_PREFIX)
async with self._flush_lock:
self._pending_vector_docs.pop(entity_id, None)
self._pending_vector_deletes.add(entity_id)
logger.debug(
f"[{self.workspace}] Buffered delete for entity {entity_name} (id={entity_id})"
)
async def delete_entity_relation(self, entity_name: str) -> None:
"""Delete all relation vectors where entity appears as src or tgt.
The whole method runs under ``_flush_lock`` so the server-side
scroll + delete cannot interleave with an in-flight bulk upsert.
Server-side failures are re-raised (no log-and-swallow): the
caller decides whether to retry.
Buffer semantics — post-prune with caller short-circuit contract:
Matching pending upserts in ``_pending_vector_docs`` are
pruned **only after** the server-side scroll+delete loop
completes fully. If any iteration raises, the pending buffer
is left intact so a higher-level failure does not silently
drop buffered relation vectors that the user never told us
to discard. The trade-off is that partial server-side
deletes plus preserved pending upserts can re-insert deleted
relations on the next flush — correctness therefore relies
on the caller short-circuiting before ``index_done_callback``
can run. The single in-tree caller ``adelete_by_entity``
in ``utils_graph.py`` honors this: its ``except`` clause
skips both ``delete_node`` and ``_persist_graph_updates``,
so on failure the graph and the pending buffer stay
consistent with the "delete never happened" state and the
operation converges on the next retry.
"""
async with self._flush_lock:
if self._client is None:
# pre-init / post-finalize: only buffer state remains, so
# apply the delete intent there.
for doc_id in [
k
for k, v in self._pending_vector_docs.items()
if v.source.get("src_id") == entity_name
or v.source.get("tgt_id") == entity_name
]:
self._pending_vector_docs.pop(doc_id, None)
return
relation_filter = models.Filter(
must=[workspace_filter_condition(self.effective_workspace)],
should=[
models.FieldCondition(
key="src_id", match=models.MatchValue(value=entity_name)
),
models.FieldCondition(
key="tgt_id", match=models.MatchValue(value=entity_name)
),
],
)
total_deleted = 0
offset = None
batch_size = 1000
while True:
results = self._client.scroll(
collection_name=self.final_namespace,
scroll_filter=relation_filter,
with_payload=False,
with_vectors=False,
limit=batch_size,
offset=offset,
)
points, next_offset = results
if not points:
break
ids_to_delete = [point.id for point in points]
self._client.delete(
collection_name=self.final_namespace,
points_selector=models.PointIdsList(points=ids_to_delete),
wait=True,
)
total_deleted += len(ids_to_delete)
if next_offset is None:
break
offset = next_offset
# Server-side scroll+delete fully succeeded — safe to prune
# matching pending relation upserts so the next flush won't
# re-upsert the just-deleted relations. If the loop above
# raised, this prune is skipped and the buffer state stays
# available for the caller's retry path.
for doc_id in [
k
for k, v in self._pending_vector_docs.items()
if v.source.get("src_id") == entity_name
or v.source.get("tgt_id") == entity_name
]:
self._pending_vector_docs.pop(doc_id, None)
if total_deleted > 0:
logger.debug(
f"[{self.workspace}] Deleted {total_deleted} relations for {entity_name}"
)
else:
logger.debug(
f"[{self.workspace}] No relations found for entity {entity_name}"
)
async def get_by_id(self, id: str) -> dict[str, Any] | None:
"""Get vector data by its ID, with read-your-writes against the buffer."""
async with self._flush_lock:
if id in self._pending_vector_deletes:
return None
pending = self._pending_vector_docs.get(id)
if pending is not None:
# Buffer hits return the source payload (no vector); the
# Qdrant fallback path also returns just the payload.
payload = dict(pending.source)
payload.setdefault(CREATED_AT_FIELD, None)
return payload
try:
qdrant_id = compute_mdhash_id_for_qdrant(
id, prefix=self.effective_workspace
)
result = self._client.retrieve(
collection_name=self.final_namespace,
ids=[qdrant_id],
with_payload=True,
)
if not result:
return None
payload = result[0].payload
if CREATED_AT_FIELD not in payload:
payload[CREATED_AT_FIELD] = None
return payload
except Exception as e:
logger.error(
f"[{self.workspace}] Error retrieving vector data for ID {id}: {e}"
)
return None
async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:
"""Get multiple vector data by their IDs (read-your-writes), preserving order."""
if not ids:
return []
buffered: dict[str, dict[str, Any] | None] = {}
remaining: list[str] = []
async with self._flush_lock:
for doc_id in ids:
if doc_id in self._pending_vector_deletes:
buffered[doc_id] = None
continue
pending = self._pending_vector_docs.get(doc_id)
if pending is not None:
payload = dict(pending.source)
payload.setdefault(CREATED_AT_FIELD, None)
buffered[doc_id] = payload
continue
remaining.append(doc_id)
payload_by_original_id: dict[str, dict[str, Any]] = {}
payload_by_qdrant_id: dict[str, dict[str, Any]] = {}
if remaining:
try:
qdrant_ids = [
compute_mdhash_id_for_qdrant(id, prefix=self.effective_workspace)
for id in remaining
]
results = self._client.retrieve(
collection_name=self.final_namespace,
ids=qdrant_ids,
with_payload=True,
)
for point in results:
payload = dict(point.payload or {})
if CREATED_AT_FIELD not in payload:
payload[CREATED_AT_FIELD] = None
qdrant_point_id = str(point.id) if point.id is not None else ""
if qdrant_point_id:
payload_by_qdrant_id[qdrant_point_id] = payload
original_id = payload.get(ID_FIELD)
if original_id is not None:
payload_by_original_id[str(original_id)] = payload
except Exception as e:
logger.error(
f"[{self.workspace}] Error retrieving vector data for IDs {remaining}: {e}"
)
return []
ordered_payloads: list[dict[str, Any] | None] = []
for doc_id in ids:
if doc_id in buffered:
ordered_payloads.append(buffered[doc_id])
continue
payload = payload_by_original_id.get(str(doc_id))
if payload is None:
payload = payload_by_qdrant_id.get(
compute_mdhash_id_for_qdrant(
doc_id, prefix=self.effective_workspace
)
)
ordered_payloads.append(payload)
return ordered_payloads
async def get_vectors_by_ids(self, ids: list[str]) -> dict[str, list[float]]:
"""Get vector embeddings for given IDs, with read-your-writes.
Pending docs whose vector hasn't been embedded yet are embedded
lazily inside the lock; the resulting vector is cached on the
buffered ``_PendingVectorDoc`` so the next flush won't re-embed.
Visibility caveat for ids not in the buffer: the server-side
``retrieve`` fallback runs *outside* ``_flush_lock``. A concurrent
``delete()`` that lands between lock release and the server read
only buffers the delete -- the old vector is still on disk
until the next flush, so this method may return a stale vector
for an id that has been buffered for deletion. This is
best-effort read-after-uncommitted-delete and matches the
``query()`` contract: callers needing strict consistency must
``index_done_callback()`` first.
"""
if not ids:
return {}
result: dict[str, list[float]] = {}
remaining: list[str] = []
async with self._flush_lock:
docs_to_embed: list[tuple[str, _PendingVectorDoc]] = []
for doc_id in ids:
if doc_id in self._pending_vector_deletes:
continue
pending = self._pending_vector_docs.get(doc_id)
if pending is not None:
if pending.vector is None:
docs_to_embed.append((doc_id, pending))
else:
result[doc_id] = pending.vector
continue
remaining.append(doc_id)
if docs_to_embed:
contents = [pdoc.content for _, pdoc in docs_to_embed]
batches = [
contents[i : i + self._max_batch_size]
for i in range(0, len(contents), self._max_batch_size)
]
try:
embeddings_list = await asyncio.gather(
*[
self.embedding_func(batch, context="document")
for batch in batches
]
)
except Exception as e:
logger.error(
f"[{self.workspace}] Error lazily embedding pending vectors "
f"(upserts={len(docs_to_embed)}): {e}"
)
raise
embeddings = np.concatenate(embeddings_list)
if len(embeddings) != len(docs_to_embed):
raise RuntimeError(
f"[{self.workspace}] Embedding count mismatch: expected "
f"{len(docs_to_embed)}, got {len(embeddings)}"
)
for i, ((doc_id, pdoc), embedding) in enumerate(
zip(docs_to_embed, embeddings), start=1
):
pdoc.vector = np.array(embedding, dtype=np.float32).tolist()
result[doc_id] = pdoc.vector
await _cooperative_yield(i)
if not remaining:
return result
try:
qdrant_ids = [
compute_mdhash_id_for_qdrant(id, prefix=self.effective_workspace)
for id in remaining
]
results = self._client.retrieve(
collection_name=self.final_namespace,
ids=qdrant_ids,
with_vectors=True,
with_payload=True,
)
for point in results:
if point and point.vector is not None and point.payload:
original_id = point.payload.get(ID_FIELD)
if original_id:
vector_data = point.vector
if isinstance(vector_data, np.ndarray):
vector_data = vector_data.tolist()
result[original_id] = vector_data
return result
except Exception as e:
logger.error(f"[{self.workspace}] Error getting vectors: {e}")
return result
async def finalize(self):
"""Flush pending vector ops, then release the Qdrant client.
The QdrantClient owns an HTTP/gRPC transport that should be closed
explicitly rather than left for GC — matching the close-on-release
pattern used by the other server-backed storages (Neo4j / Postgres /
Mongo / OpenSearch / Milvus). We still fail loudly when a transient
bulk error left writes buffered. ``_flush_pending_vector_ops`` is
all-or-nothing: it either clears both buffers or raises with
them intact, but we still defensively check both buffers after a
successful flush in case a future refactor breaks that invariant.
"""
flush_error: Exception | None = None
try:
await self._flush_pending_vector_ops()
except Exception as e:
flush_error = e
# Release the client after the flush so the flush can still use it.
# The transport is freed on every exit path, matching the
# close-on-release pattern of the other server-backed storages.
if self._client is not None:
try:
self._client.close()
except Exception as close_error:
logger.warning(
f"[{self.workspace}] Failed to close Qdrant client: {close_error}"
)
async with self._flush_lock:
pending_docs = len(self._pending_vector_docs)
pending_deletes = len(self._pending_vector_deletes)
if flush_error is not None:
raise RuntimeError(
f"[{self.workspace}] QdrantVectorDBStorage.finalize() flush raised; "
f"{pending_docs} pending upserts and {pending_deletes} pending "
f"deletes were left buffered (data lost)"
) from flush_error
if pending_docs or pending_deletes:
raise RuntimeError(
f"[{self.workspace}] QdrantVectorDBStorage.finalize() left "
f"{pending_docs} pending upserts and {pending_deletes} pending "
f"deletes buffered after final flush attempt (these writes have been lost)"
)
async def drop(self) -> dict[str, str]:
"""Drop all vector data for the current workspace. Destructive.
Deletes every point matching ``effective_workspace`` from the
shared Qdrant collection ``final_namespace`` (Qdrant partitions a
single physical collection across workspaces via the
``workspace_id`` payload field, so sibling workspaces on the same
collection are untouched). The collection itself and its vector
index are NOT recreated — they were provisioned at
``initialize()`` and remain in place.
The same workspace-scoped delete is also issued against the kept
legacy collection (the un-suffixed collection that the model-suffix
migration leaves behind as a backup), when one is found. The
legacy->suffixed migration only runs while the suffixed collection
has no points for the workspace; if a deliberate clear left this
workspace's data behind in legacy, the next startup would migrate
it back into the freshly-emptied suffixed collection (resurrection).
Only this workspace's legacy points are removed, so other
workspaces' legacy data and their pending one-time migration stay
intact.
MUST only be called when ``pipeline_status`` is idle (see the
Pipeline concurrency contract in ``AGENTS.md``); the only
in-tree caller ``clear_documents`` enforces this.
Pending-write buffers are cleared *before* the server-side delete
is issued so a concurrent flush on this instance cannot resurrect
the dropped data. As a consequence, if the server-side delete
fails, the buffered writes are also lost — the caller cannot
recover them by retrying ``drop()``. This matches ``drop()``'s
contract ("discard everything for this workspace") and the other
lazy-embedding backends.
Caveat — only this instance's buffers are cleared. Other
``QdrantVectorDBStorage`` instances aliased onto the same
``(final_namespace, effective_workspace)`` (multi-worker
processes, or distinct workspaces collapsed by
``QDRANT_WORKSPACE``) keep their own buffers; a sibling whose
prior flush failed and left buffers intact will, on its next
flush, upsert those stale points back into the freshly emptied
workspace. Direct callers bypassing the idle precondition MUST
flush every aliased instance first.
Returns:
dict[str, str]: ``{"status": "success"|"error", "message": str}``
"""
try:
async with self._flush_lock:
# Discard buffered writes before the workspace is wiped;
# a concurrent flush would otherwise resurrect them.
self._pending_vector_docs.clear()
self._pending_vector_deletes.clear()
# Delete all points for the current workspace
workspace_selector = models.FilterSelector(
filter=models.Filter(
must=[workspace_filter_condition(self.effective_workspace)]
)
)
self._client.delete(
collection_name=self.final_namespace,
points_selector=workspace_selector,
wait=True,
)
# Also clear this workspace's data from the kept legacy
# collection so the next startup does not re-migrate the
# just-cleared data back into the suffixed collection.
legacy_collection = _find_legacy_collection(
self._client,
self.namespace,
self.effective_workspace,
self.model_suffix,
)
if legacy_collection and legacy_collection != self.final_namespace:
legacy_has_workspace = _legacy_collection_has_workspace_field(
self._client, legacy_collection
)
if legacy_has_workspace is None:
# Tagging undetermined (transient metadata / scroll error):
# do NOT drop the collection (it may be an actually-tagged,
# shared legacy and we'd delete other workspaces' migration
# source). But the legacy data is left untouched, so the
# next startup would re-migrate this workspace's cleared
# points back — the clear is NOT durable. Abort with an
# error so the caller can retry instead of reporting a
# success that does not survive a restart.
raise RuntimeError(
f"Could not determine workspace tagging of legacy "
f"collection '{legacy_collection}'; aborting clear of "
f"'{self.namespace}' to avoid leaving stale legacy data "
f"that would resurrect on restart. Retry once Qdrant "
f"metadata is reachable."
)
elif legacy_has_workspace:
# Workspace-tagged legacy: remove only this workspace's points.
self._client.delete(
collection_name=legacy_collection,
points_selector=workspace_selector,
wait=True,
)
else:
# Untagged (pre-isolation) legacy: setup_collection migrates
# ALL of its points into this workspace with no workspace
# filter, so a workspace-filtered delete would miss them.
# Drop the whole legacy collection to remove the migration
# source.
self._client.delete_collection(
collection_name=legacy_collection
)
logger.info(
f"[{self.workspace}] Dropped untagged legacy Qdrant collection "
f"'{legacy_collection}' on workspace clear"
)
logger.info(
f"[{self.workspace}] Process {os.getpid()} dropped workspace data from Qdrant collection {self.namespace}"
)
return {"status": "success", "message": "data dropped"}
except Exception as e:
logger.error(
f"[{self.workspace}] Error dropping workspace data from Qdrant collection {self.namespace}: {e}"
)
return {"status": "error", "message": str(e)}