Files
hkuds--lightrag/lightrag/kg/mongo_impl.py
T
2026-07-13 12:08:54 +08:00

3695 lines
150 KiB
Python

import os
import re
import json
import time
from dataclasses import dataclass, field
import numpy as np
import configparser
import asyncio
from typing import Any, Union, final
from ..base import (
BaseGraphStorage,
BaseKVStorage,
BaseVectorStorage,
DocProcessingStatus,
DocStatus,
DocStatusStorage,
)
from ..utils import (
logger,
compute_mdhash_id,
_cooperative_yield,
merge_source_ids,
validate_workspace,
)
from ..types import KnowledgeGraph, KnowledgeGraphNode, KnowledgeGraphEdge
from ..constants import GRAPH_FIELD_SEP, DEFAULT_QUERY_PRIORITY
from .._version import __version__
from ..kg.shared_storage import get_data_init_lock, get_namespace_lock
import pipmaster as pm
if not pm.is_installed("pymongo"):
pm.install("pymongo")
from pymongo import AsyncMongoClient # type: ignore
from pymongo import UpdateOne # type: ignore
from pymongo.asynchronous.database import AsyncDatabase # type: ignore
from pymongo.asynchronous.collection import AsyncCollection # type: ignore
from pymongo.operations import SearchIndexModel # type: ignore
from pymongo.driver_info import DriverInfo # type: ignore
from pymongo.errors import ( # type: ignore
PyMongoError,
DuplicateKeyError,
BulkWriteError,
)
config = configparser.ConfigParser()
config.read("config.ini", "utf-8")
GRAPH_BFS_MODE = os.getenv("MONGO_GRAPH_BFS_MODE", "bidirectional")
# Flush-time batching limits shared by every MongoDB upsert path
# (MongoVectorDBStorage, MongoKVStorage, MongoGraphStorage).
# The payload-byte budget is the primary limiter; the record-count caps are a
# secondary guard that only binds when individual records are small.
# Upsert and delete have separate count caps on purpose: upsert records each
# carry a full embedding vector and are far heavier than delete _ids, so the
# upsert batch count is kept much smaller than the delete one.
# MongoDB caps a single BSON document at 16MB and a single bulk command message
# at 48MB; a 16MB JSON estimate (which overestimates the real BSON size) keeps
# every bulk_write comfortably below the wire limit and bounds peak memory.
DEFAULT_MONGO_UPSERT_MAX_PAYLOAD_BYTES = 16 * 1024 * 1024 # 16MB
DEFAULT_MONGO_UPSERT_MAX_RECORDS_PER_BATCH = 128
DEFAULT_MONGO_DELETE_MAX_RECORDS_PER_BATCH = 1000
# MongoDB duplicate-key error code, raised when an upsert insert races the
# unique edge-endpoint index (another writer inserted the same edge first).
_DUPLICATE_KEY_CODE = 11000
# Emit a migration progress line every this many deduped docs, so operators
# watching a large migration see liveness (mirrors the OpenSearch canonical-id
# migration's progress cadence).
_EDGE_MIGRATION_PROGRESS_INTERVAL = 50_000
def _canonical_edge_endpoints(
source_node_id: str, target_node_id: str
) -> tuple[str, str]:
"""Direction-independent ``(edge_lo, edge_hi)`` endpoints for an undirected edge.
The sorted pair maps ``(A,B)`` and ``(B,A)`` to the same two field values,
so a *compound* unique index on ``(edge_lo, edge_hi)`` lets MongoDB reject
the second of two racing inserts (the classic ``$or``-upsert duplicate gap)
regardless of direction. Storing the endpoints as two separate fields — not
a single delimiter-joined string — avoids any collision between distinct
pairs whose ids happen to contain the delimiter (e.g. custom-KG ids), and
needs no input sanitisation. Reads keep using the bidirectional ``$or``.
"""
return tuple(sorted((source_node_id, target_node_id))) # type: ignore[return-value]
def _edge_source_id_list(doc: dict[str, Any]) -> list[str]:
"""Return an edge doc's source ids, from the ``source_ids`` array or by
splitting the ``GRAPH_FIELD_SEP``-joined ``source_id`` string."""
sids = doc.get("source_ids")
if not sids and doc.get("source_id"):
sids = doc["source_id"].split(GRAPH_FIELD_SEP)
return list(sids or [])
def _coerce_weight(weight: Any) -> float | None:
"""Coerce a (possibly string) edge weight to float, or None if non-numeric."""
if weight is None:
return None
try:
return float(weight)
except (TypeError, ValueError):
return None
def _estimate_doc_bytes(doc: Any) -> int:
"""Estimate a document's serialized byte size via compact JSON.
JSON overestimates the real BSON size MongoDB writes (a JSON float string is
far longer than the 8 bytes a BSON double encodes), so callers stay
conservatively below server limits and never underestimate.
This is a splitting *heuristic*, not the exact wire size: upsert callers pass
only the dominant payload field (the ``$set`` body / ``update_doc``), not the
full ``UpdateOne`` op (filter, ``$setOnInsert``, ``$or`` wrapper). Those extras
are tiny next to an embedding/document body, and the 16MB estimate budget sits
far under MongoDB's 48MB bulk-command limit, so the under-count is immaterial;
the server stays the final arbiter.
"""
return len(
json.dumps(doc, ensure_ascii=False, separators=(",", ":"), default=str).encode(
"utf-8"
)
)
def _chunk_by_budget(
items: list[Any],
size_of,
max_payload_bytes: int,
max_records_per_batch: int,
) -> list[tuple[list[Any], int]]:
"""Split items into batches by estimated payload size (primary) and count.
The byte budget is the primary limiter: items accumulate until adding the
next one would exceed ``max_payload_bytes``, then a new batch starts.
``size_of(item)`` returns an item's estimated serialized byte size. A single
item larger than the byte budget is emitted as its own batch rather than
raising; the server stays the final arbiter. A non-positive limit disables
that dimension. Returns ``(batch, summed_estimated_bytes)`` tuples (the
estimate is used for logging).
"""
if not items:
return []
payload_limit = max_payload_bytes if max_payload_bytes > 0 else float("inf")
records_limit = max_records_per_batch if max_records_per_batch > 0 else float("inf")
batches: list[tuple[list[Any], int]] = []
current: list[Any] = []
# JSON array overhead ("[]")
current_bytes = 2
for item in items:
item_bytes = size_of(item)
# If current batch not empty, a comma is needed before next element.
separator_overhead = 1 if current else 0
next_bytes = current_bytes + separator_overhead + item_bytes
if current and (len(current) >= records_limit or next_bytes > payload_limit):
batches.append((current, current_bytes))
current = []
current_bytes = 2
next_bytes = current_bytes + item_bytes
current.append(item)
current_bytes = next_bytes
if current:
batches.append((current, current_bytes))
return batches
def _resolve_upsert_batch_limits() -> tuple[int, int]:
"""Resolve flush-time upsert batching limits from env, with module defaults.
Shared by every MongoDB upsert path so the byte/record caps that bound a
single ``bulk_write`` are consistent across all of them. A non-positive
value disables that splitting dimension.
"""
max_payload_bytes = int(
os.getenv(
"MONGO_UPSERT_MAX_PAYLOAD_BYTES",
str(DEFAULT_MONGO_UPSERT_MAX_PAYLOAD_BYTES),
)
)
max_records_per_batch = int(
os.getenv(
"MONGO_UPSERT_MAX_RECORDS_PER_BATCH",
str(DEFAULT_MONGO_UPSERT_MAX_RECORDS_PER_BATCH),
)
)
if max_payload_bytes <= 0:
logger.warning(
f"MONGO_UPSERT_MAX_PAYLOAD_BYTES={max_payload_bytes} is non-positive, disable payload-size splitting"
)
if max_records_per_batch <= 0:
logger.warning(
f"MONGO_UPSERT_MAX_RECORDS_PER_BATCH={max_records_per_batch} is non-positive, disable upsert record-count splitting"
)
return max_payload_bytes, max_records_per_batch
def _resolve_delete_batch_limit() -> int:
"""Resolve the flush-time delete record-count cap from env, with module default.
Shared by every MongoDB delete path that fans a list of match clauses into a
single server message (``delete_many`` with ``$in``/``$or``), so the cap that
keeps one delete under the bulk message / 16MB query limit is consistent. A
non-positive value disables record-count splitting.
"""
max_records_per_batch = int(
os.getenv(
"MONGO_DELETE_MAX_RECORDS_PER_BATCH",
str(DEFAULT_MONGO_DELETE_MAX_RECORDS_PER_BATCH),
)
)
if max_records_per_batch <= 0:
logger.warning(
f"MONGO_DELETE_MAX_RECORDS_PER_BATCH={max_records_per_batch} is non-positive, disable delete record-count splitting"
)
return max_records_per_batch
async def _run_batched_bulk_write(
collection,
ops: list[tuple[Any, int, str]],
*,
max_payload_bytes: int,
max_records_per_batch: int,
ordered: bool,
log_prefix: str,
what: str,
) -> None:
"""Execute UpdateOne ops as payload-size/record-count bounded bulk_write batches.
``ops`` is a list of ``(operation, estimated_bytes, id_for_log)`` triples.
Splitting keeps each bulk command below MongoDB's 48MB message ceiling and
bounds the in-memory op list. Fail-fast: a batch failure raises and no
further batches run, so callers must treat the whole write as retryable
(UpdateOne(..., upsert=True) is idempotent).
"""
if not ops:
return
batches = _chunk_by_budget(
ops, lambda triple: triple[1], max_payload_bytes, max_records_per_batch
)
if len(batches) > 1:
logger.info(
f"{log_prefix} {what} split into {len(batches)} batches "
f"for {len(ops)} records"
)
for batch_index, (batch, estimated_bytes) in enumerate(batches, 1):
if (
len(batch) == 1
and max_payload_bytes > 0
and estimated_bytes > max_payload_bytes
):
logger.warning(
f"{log_prefix} {what}: single record id={batch[0][2]} "
f"estimated {estimated_bytes} bytes exceeds {max_payload_bytes}"
)
logger.debug(
f"{log_prefix} {what} batch {batch_index}/{len(batches)}: "
f"records={len(batch)}, estimated_payload_bytes={estimated_bytes}"
)
await collection.bulk_write([triple[0] for triple in batch], ordered=ordered)
class ClientManager:
_instances: dict = {"client": None, "db": None, "ref_count": 0}
_lock = asyncio.Lock()
@classmethod
async def get_client(cls) -> AsyncMongoClient:
async with cls._lock:
if cls._instances["db"] is None:
uri = os.environ.get(
"MONGO_URI",
config.get(
"mongodb",
"uri",
fallback="mongodb://root:root@localhost:27017/",
),
)
database_name = os.environ.get(
"MONGO_DATABASE",
config.get("mongodb", "database", fallback="LightRAG"),
)
client = AsyncMongoClient(
uri,
driver=DriverInfo(name="LightRAG", version=__version__),
)
db = client.get_database(database_name)
cls._instances["client"] = client
cls._instances["db"] = db
cls._instances["ref_count"] = 0
cls._instances["ref_count"] += 1
return cls._instances["db"]
@classmethod
async def release_client(cls, db: AsyncDatabase):
async with cls._lock:
if db is not None:
if db is cls._instances["db"]:
cls._instances["ref_count"] -= 1
if cls._instances["ref_count"] == 0:
client = cls._instances.get("client")
if client is not None:
await client.close()
cls._instances["client"] = None
cls._instances["db"] = None
@final
@dataclass
class MongoKVStorage(BaseKVStorage):
db: AsyncDatabase = field(default=None)
_data: AsyncCollection = field(default=None)
def __init__(self, namespace, global_config, embedding_func, workspace=None):
super().__init__(
namespace=namespace,
workspace=workspace or "",
global_config=global_config,
embedding_func=embedding_func,
)
self.__post_init__()
def __post_init__(self):
validate_workspace(self.workspace)
# Check for MONGODB_WORKSPACE environment variable first (higher priority)
# This allows administrators to force a specific workspace for all MongoDB storage instances
mongodb_workspace = os.environ.get("MONGODB_WORKSPACE")
if mongodb_workspace and mongodb_workspace.strip():
# Use environment variable value, overriding the passed workspace parameter
effective_workspace = mongodb_workspace.strip()
logger.info(
f"Using MONGODB_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}'"
)
# Build final_namespace with workspace prefix for data isolation
# Keep original namespace unchanged for type detection logic
if effective_workspace:
self.final_namespace = f"{effective_workspace}_{self.namespace}"
self.workspace = effective_workspace
logger.debug(
f"Final namespace with workspace prefix: '{self.final_namespace}'"
)
else:
# When workspace is empty, final_namespace equals original namespace
self.final_namespace = self.namespace
self.workspace = ""
logger.debug(
f"[{self.workspace}] Final namespace (no workspace): '{self.namespace}'"
)
self._collection_name = self.final_namespace
(
self._max_upsert_payload_bytes,
self._max_upsert_records_per_batch,
) = _resolve_upsert_batch_limits()
async def initialize(self):
async with get_data_init_lock():
if self.db is None:
self.db = await ClientManager.get_client()
self._data = await get_or_create_collection(self.db, self._collection_name)
logger.debug(
f"[{self.workspace}] Use MongoDB as KV {self._collection_name}"
)
async def finalize(self):
if self.db is not None:
await ClientManager.release_client(self.db)
self.db = None
self._data = None
async def get_by_id(self, id: str) -> dict[str, Any] | None:
# Unified handling for flattened keys
doc = await self._data.find_one({"_id": id})
if doc:
# Ensure time fields are present, provide default values for old data
doc.setdefault("create_time", 0)
doc.setdefault("update_time", 0)
return doc
async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:
cursor = self._data.find({"_id": {"$in": ids}})
docs = await cursor.to_list(length=None)
doc_map: dict[str, dict[str, Any]] = {}
for doc in docs:
if not doc:
continue
doc.setdefault("create_time", 0)
doc.setdefault("update_time", 0)
doc_map[str(doc.get("_id"))] = doc
ordered_results: list[dict[str, Any] | None] = []
for id_value in ids:
ordered_results.append(doc_map.get(str(id_value)))
return ordered_results
async def filter_keys(self, keys: set[str]) -> set[str]:
cursor = self._data.find({"_id": {"$in": list(keys)}}, {"_id": 1})
existing_ids = {str(x["_id"]) async for x in cursor}
return keys - existing_ids
async def upsert(self, data: dict[str, dict[str, Any]]) -> None:
logger.debug(f"[{self.workspace}] Inserting {len(data)} to {self.namespace}")
if not data:
return
# Unified handling for all namespaces with flattened keys. KV docs
# (full_docs, text_chunks, llm_response_cache) can be large, so the
# upsert is split into payload-bounded bulk_write batches.
operations: list[tuple[Any, int, str]] = []
current_time = int(time.time()) # Get current Unix timestamp
for i, (k, v) in enumerate(data.items(), start=1):
# For text_chunks namespace, ensure llm_cache_list field exists
if self.namespace.endswith("text_chunks"):
if "llm_cache_list" not in v:
v["llm_cache_list"] = []
# Create a copy of v for $set operation, excluding create_time to avoid conflicts
v_for_set = v.copy()
v_for_set["_id"] = k # Use flattened key as _id
v_for_set["update_time"] = current_time # Always update update_time
# Remove create_time from $set to avoid conflict with $setOnInsert
v_for_set.pop("create_time", None)
operations.append(
(
UpdateOne(
{"_id": k},
{
"$set": v_for_set, # Update all fields except create_time
"$setOnInsert": {
"create_time": current_time
}, # Set create_time only on insert
},
upsert=True,
),
_estimate_doc_bytes(v_for_set),
k,
)
)
await _cooperative_yield(i)
# ordered=False (intentional): the old single bulk_write used pymongo's
# default ordered=True, but every op targets a distinct flattened _id, so
# the writes are order-independent. ordered=False lets the server apply
# them in parallel and is the right choice for idempotent upserts.
await _run_batched_bulk_write(
self._data,
operations,
max_payload_bytes=self._max_upsert_payload_bytes,
max_records_per_batch=self._max_upsert_records_per_batch,
ordered=False,
log_prefix=f"[{self.workspace}] {self.namespace} upsert:",
what="upsert",
)
async def index_done_callback(self) -> None:
# Mongo handles persistence automatically
pass
async def is_empty(self) -> bool:
"""Check if the storage is empty for the current workspace and namespace
Returns:
bool: True if storage is empty, False otherwise
"""
try:
# Use count_documents with limit 1 for efficiency
count = await self._data.count_documents({}, limit=1)
return count == 0
except PyMongoError as e:
logger.error(f"[{self.workspace}] Error checking if storage is empty: {e}")
return True
async def delete(self, ids: list[str]) -> None:
"""Delete documents with specified IDs
Args:
ids: List of document IDs to be deleted
"""
if not ids:
return
# Convert to list if it's a set (MongoDB BSON cannot encode sets)
if isinstance(ids, set):
ids = list(ids)
try:
result = await self._data.delete_many({"_id": {"$in": ids}})
logger.info(
f"[{self.workspace}] Deleted {result.deleted_count} documents from {self.namespace}"
)
except PyMongoError as e:
logger.error(
f"[{self.workspace}] Error deleting documents from {self.namespace}: {e}"
)
async def drop(self) -> dict[str, str]:
"""Drop the storage by removing all documents in the collection.
Returns:
dict[str, str]: Status of the operation with keys 'status' and 'message'
"""
try:
result = await self._data.delete_many({})
deleted_count = result.deleted_count
logger.info(
f"[{self.workspace}] Dropped {deleted_count} documents from doc status {self._collection_name}"
)
return {
"status": "success",
"message": f"{deleted_count} documents dropped",
}
except PyMongoError as e:
logger.error(
f"[{self.workspace}] Error dropping doc status {self._collection_name}: {e}"
)
return {"status": "error", "message": str(e)}
@final
@dataclass
class MongoDocStatusStorage(DocStatusStorage):
db: AsyncDatabase = field(default=None)
_data: AsyncCollection = field(default=None)
def _prepare_doc_status_data(self, doc: dict[str, Any]) -> dict[str, Any]:
"""Normalize and migrate a raw Mongo document to DocProcessingStatus-compatible dict."""
# Make a copy of the data to avoid modifying the original
data = doc.copy()
# Remove deprecated content field if it exists
data.pop("content", None)
# Remove MongoDB _id field if it exists
data.pop("_id", None)
# If file_path is not in data, use document id as file path
if "file_path" not in data:
data["file_path"] = "no-file-path"
# Ensure new fields exist with default values
if "metadata" not in data:
data["metadata"] = {}
if "error_msg" not in data:
data["error_msg"] = None
# Backward compatibility: migrate legacy 'error' field to 'error_msg'
if "error" in data:
if "error_msg" not in data or data["error_msg"] in (None, ""):
data["error_msg"] = data.pop("error")
else:
data.pop("error", None)
return data
def __init__(self, namespace, global_config, embedding_func, workspace=None):
super().__init__(
namespace=namespace,
workspace=workspace or "",
global_config=global_config,
embedding_func=embedding_func,
)
self.__post_init__()
def __post_init__(self):
validate_workspace(self.workspace)
# Check for MONGODB_WORKSPACE environment variable first (higher priority)
# This allows administrators to force a specific workspace for all MongoDB storage instances
mongodb_workspace = os.environ.get("MONGODB_WORKSPACE")
if mongodb_workspace and mongodb_workspace.strip():
# Use environment variable value, overriding the passed workspace parameter
effective_workspace = mongodb_workspace.strip()
logger.info(
f"Using MONGODB_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}'"
)
# Build final_namespace with workspace prefix for data isolation
# Keep original namespace unchanged for type detection logic
if effective_workspace:
self.final_namespace = f"{effective_workspace}_{self.namespace}"
self.workspace = effective_workspace
logger.debug(
f"Final namespace with workspace prefix: '{self.final_namespace}'"
)
else:
# When workspace is empty, final_namespace equals original namespace
self.final_namespace = self.namespace
self.workspace = ""
logger.debug(f"Final namespace (no workspace): '{self.final_namespace}'")
self._collection_name = self.final_namespace
async def initialize(self):
async with get_data_init_lock():
if self.db is None:
self.db = await ClientManager.get_client()
self._data = await get_or_create_collection(self.db, self._collection_name)
# Create and migrate all indexes including Chinese collation for file_path
await self.create_and_migrate_indexes_if_not_exists()
logger.debug(
f"[{self.workspace}] Use MongoDB as DocStatus {self._collection_name}"
)
async def finalize(self):
if self.db is not None:
await ClientManager.release_client(self.db)
self.db = None
self._data = None
async def get_by_id(self, id: str) -> Union[dict[str, Any], None]:
return await self._data.find_one({"_id": id})
async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:
cursor = self._data.find({"_id": {"$in": ids}})
docs = await cursor.to_list(length=None)
doc_map: dict[str, dict[str, Any]] = {}
for doc in docs:
if not doc:
continue
doc_map[str(doc.get("_id"))] = doc
ordered_results: list[dict[str, Any] | None] = []
for id_value in ids:
ordered_results.append(doc_map.get(str(id_value)))
return ordered_results
async def filter_keys(self, data: set[str]) -> set[str]:
cursor = self._data.find({"_id": {"$in": list(data)}}, {"_id": 1})
existing_ids = {str(x["_id"]) async for x in cursor}
return data - existing_ids
async def upsert(self, data: dict[str, dict[str, Any]]) -> None:
logger.debug(f"[{self.workspace}] Inserting {len(data)} to {self.namespace}")
if not data:
return
update_tasks: list[Any] = []
for i, (k, v) in enumerate(data.items(), start=1):
# Ensure chunks_list field exists and is an array
if "chunks_list" not in v:
v["chunks_list"] = []
data[k]["_id"] = k
update_tasks.append(
self._data.update_one({"_id": k}, {"$set": v}, upsert=True)
)
await _cooperative_yield(i)
await asyncio.gather(*update_tasks)
async def get_status_counts(self) -> dict[str, int]:
"""Get counts of documents in each status"""
pipeline = [{"$group": {"_id": "$status", "count": {"$sum": 1}}}]
cursor = await self._data.aggregate(pipeline, allowDiskUse=True)
result = await cursor.to_list()
counts = {}
for doc in result:
counts[doc["_id"]] = doc["count"]
return counts
async def get_docs_by_status(
self, status: DocStatus
) -> dict[str, DocProcessingStatus]:
"""Get all documents with a specific status"""
return await self.get_docs_by_statuses([status])
async def get_docs_by_statuses(
self, statuses: list[DocStatus]
) -> dict[str, DocProcessingStatus]:
"""Get all documents matching any of the given statuses in a single query.
Uses MongoDB's $in operator to fetch all matching statuses in one
round-trip instead of one find() call per status.
"""
if not statuses:
return {}
status_values = [s.value for s in statuses]
cursor = self._data.find({"status": {"$in": status_values}})
docs = await cursor.to_list(length=None)
result = {}
for doc in docs:
try:
data = self._prepare_doc_status_data(doc)
result[doc["_id"]] = DocProcessingStatus(**data)
except KeyError as e:
logger.error(
f"[{self.workspace}] Missing required field for document {doc['_id']}: {e}"
)
continue
return result
async def get_docs_by_track_id(
self, track_id: str
) -> dict[str, DocProcessingStatus]:
"""Get all documents with a specific track_id"""
cursor = self._data.find({"track_id": track_id})
result = await cursor.to_list()
processed_result = {}
for doc in result:
try:
data = self._prepare_doc_status_data(doc)
processed_result[doc["_id"]] = DocProcessingStatus(**data)
except KeyError as e:
logger.error(
f"[{self.workspace}] Missing required field for document {doc['_id']}: {e}"
)
continue
return processed_result
async def index_done_callback(self) -> None:
# Mongo handles persistence automatically
pass
async def is_empty(self) -> bool:
"""Check if the storage is empty for the current workspace and namespace
Returns:
bool: True if storage is empty, False otherwise
"""
try:
# Use count_documents with limit 1 for efficiency
count = await self._data.count_documents({}, limit=1)
return count == 0
except PyMongoError as e:
logger.error(f"[{self.workspace}] Error checking if storage is empty: {e}")
return True
async def drop(self) -> dict[str, str]:
"""Drop the storage by removing all documents in the collection.
Returns:
dict[str, str]: Status of the operation with keys 'status' and 'message'
"""
try:
result = await self._data.delete_many({})
deleted_count = result.deleted_count
logger.info(
f"[{self.workspace}] Dropped {deleted_count} documents from doc status {self._collection_name}"
)
return {
"status": "success",
"message": f"{deleted_count} documents dropped",
}
except PyMongoError as e:
logger.error(
f"[{self.workspace}] Error dropping doc status {self._collection_name}: {e}"
)
return {"status": "error", "message": str(e)}
async def delete(self, ids: list[str]) -> None:
# Convert to list if it's a set (MongoDB BSON cannot encode sets)
if isinstance(ids, set):
ids = list(ids)
await self._data.delete_many({"_id": {"$in": ids}})
async def create_and_migrate_indexes_if_not_exists(self):
"""Create indexes to optimize pagination queries and migrate file_path indexes for Chinese collation"""
try:
# Get indexes for the current collection only
indexes_cursor = await self._data.list_indexes()
existing_indexes = await indexes_cursor.to_list(length=None)
existing_index_names = {idx.get("name", "") for idx in existing_indexes}
# Define collation configuration for Chinese pinyin sorting
collation_config = {"locale": "zh", "numericOrdering": True}
# Use workspace-specific index names to avoid cross-workspace conflicts
workspace_prefix = f"{self.workspace}_" if self.workspace != "" else ""
# 1. Define all indexes needed with workspace-specific names
all_indexes = [
# Original pagination indexes
{
"name": f"{workspace_prefix}status_updated_at",
"keys": [("status", 1), ("updated_at", -1)],
},
{
"name": f"{workspace_prefix}status_created_at",
"keys": [("status", 1), ("created_at", -1)],
},
{"name": f"{workspace_prefix}updated_at", "keys": [("updated_at", -1)]},
{"name": f"{workspace_prefix}created_at", "keys": [("created_at", -1)]},
{"name": f"{workspace_prefix}id", "keys": [("_id", 1)]},
{"name": f"{workspace_prefix}track_id", "keys": [("track_id", 1)]},
# New file_path indexes with Chinese collation and workspace-specific names
{
"name": f"{workspace_prefix}file_path_zh_collation",
"keys": [("file_path", 1)],
"collation": collation_config,
},
{
"name": f"{workspace_prefix}status_file_path_zh_collation",
"keys": [("status", 1), ("file_path", 1)],
"collation": collation_config,
},
# Partial index on content_hash for content-based dedup lookups.
# Mirrors the PG partial index: skip legacy/empty values so the
# index stays small and a content_hash="" query is a guaranteed miss.
{
"name": f"{workspace_prefix}content_hash",
"keys": [("content_hash", 1)],
"partialFilterExpression": {
"content_hash": {"$exists": True, "$type": "string", "$gt": ""}
},
},
]
# 2. Handle legacy index cleanup: only drop old indexes that exist in THIS collection
legacy_index_names = [
"file_path_zh_collation",
"status_file_path_zh_collation",
"status_updated_at",
"status_created_at",
"updated_at",
"created_at",
"id",
"track_id",
"content_hash",
]
for legacy_name in legacy_index_names:
if (
legacy_name in existing_index_names
and legacy_name
!= f"{workspace_prefix}{legacy_name.replace(workspace_prefix, '')}"
):
try:
await self._data.drop_index(legacy_name)
logger.debug(
f"[{self.workspace}] Migrated: dropped legacy index '{legacy_name}' from collection {self._collection_name}"
)
existing_index_names.discard(legacy_name)
except PyMongoError as drop_error:
logger.warning(
f"[{self.workspace}] Failed to drop legacy index '{legacy_name}' from collection {self._collection_name}: {drop_error}"
)
# 3. Create all needed indexes with workspace-specific names
for index_info in all_indexes:
index_name = index_info["name"]
if index_name not in existing_index_names:
create_kwargs = {"name": index_name}
if "collation" in index_info:
create_kwargs["collation"] = index_info["collation"]
if "partialFilterExpression" in index_info:
create_kwargs["partialFilterExpression"] = index_info[
"partialFilterExpression"
]
try:
await self._data.create_index(
index_info["keys"], **create_kwargs
)
logger.debug(
f"[{self.workspace}] Created index '{index_name}' for collection {self._collection_name}"
)
except PyMongoError as create_error:
# If creation still fails, log the error but continue with other indexes
logger.error(
f"[{self.workspace}] Failed to create index '{index_name}' for collection {self._collection_name}: {create_error}"
)
else:
logger.debug(
f"[{self.workspace}] Index '{index_name}' already exists for collection {self._collection_name}"
)
except PyMongoError as e:
logger.error(
f"[{self.workspace}] Error creating/migrating indexes for {self._collection_name}: {e}"
)
async def get_docs_paginated(
self,
status_filter: DocStatus | None = None,
status_filters: list[DocStatus] | None = None,
page: int = 1,
page_size: int = 50,
sort_field: str = "updated_at",
sort_direction: str = "desc",
) -> tuple[list[tuple[str, DocProcessingStatus]], int]:
"""Get documents with pagination support
Args:
status_filter: Filter by document status, None for all statuses
page: Page number (1-based)
page_size: Number of documents per page (10-200)
sort_field: Field to sort by ('created_at', 'updated_at', '_id')
sort_direction: Sort direction ('asc' or 'desc')
Returns:
Tuple of (list of (doc_id, DocProcessingStatus) tuples, total_count)
"""
status_filter_values = self.resolve_status_filter_values(
status_filter=status_filter,
status_filters=status_filters,
)
# Validate parameters
if page < 1:
page = 1
if page_size < 10:
page_size = 10
elif page_size > 200:
page_size = 200
if sort_field not in ["created_at", "updated_at", "_id", "file_path"]:
sort_field = "updated_at"
if sort_direction.lower() not in ["asc", "desc"]:
sort_direction = "desc"
# Build query filter
query_filter = {}
if status_filter_values is not None:
query_filter["status"] = {"$in": sorted(status_filter_values)}
# Get total count
total_count = await self._data.count_documents(query_filter)
# Calculate skip value
skip = (page - 1) * page_size
# Build sort criteria
sort_direction_value = 1 if sort_direction.lower() == "asc" else -1
sort_criteria = [(sort_field, sort_direction_value)]
# Query for paginated data with Chinese collation for file_path sorting
if sort_field == "file_path":
# Use Chinese collation for pinyin sorting
cursor = (
self._data.find(query_filter)
.sort(sort_criteria)
.collation({"locale": "zh", "numericOrdering": True})
.skip(skip)
.limit(page_size)
)
else:
# Use default sorting for other fields
cursor = (
self._data.find(query_filter)
.sort(sort_criteria)
.skip(skip)
.limit(page_size)
)
result = await cursor.to_list(length=page_size)
# Convert to (doc_id, DocProcessingStatus) tuples
documents = []
for doc in result:
try:
doc_id = doc["_id"]
data = self._prepare_doc_status_data(doc)
doc_status = DocProcessingStatus(**data)
documents.append((doc_id, doc_status))
except KeyError as e:
logger.error(
f"[{self.workspace}] Missing required field for document {doc['_id']}: {e}"
)
continue
return documents, total_count
async def get_all_status_counts(self) -> dict[str, int]:
"""Get counts of documents in each status for all documents
Returns:
Dictionary mapping status names to counts, including 'all' field
"""
pipeline = [{"$group": {"_id": "$status", "count": {"$sum": 1}}}]
cursor = await self._data.aggregate(pipeline, allowDiskUse=True)
result = await cursor.to_list()
counts = {}
total_count = 0
for doc in result:
counts[doc["_id"]] = doc["count"]
total_count += doc["count"]
# Add 'all' field with total count
counts["all"] = total_count
return counts
async def get_doc_by_file_path(self, file_path: str) -> Union[dict[str, Any], None]:
"""Get document by file path
Args:
file_path: The file path to search for
Returns:
Union[dict[str, Any], None]: Document data if found, None otherwise
Returns the same format as get_by_id method
"""
return await self._data.find_one({"file_path": file_path})
async def get_doc_by_file_basename(
self, basename: str
) -> Union[tuple[str, dict[str, Any]], None]:
"""Mongo-native override of basename-based document lookup.
The caller is responsible for passing an already-canonical basename;
stored ``file_path`` values are canonicalized by the business layer, so
this lookup performs an exact match only and relies on the file_path
index created by ``create_and_migrate_indexes_if_not_exists``.
"""
if not basename:
return None
if basename == "unknown_source":
return None
try:
doc = await self._data.find_one({"file_path": basename})
except PyMongoError as e:
logger.error(f"[{self.workspace}] Error in get_doc_by_file_basename: {e}")
return None
if not doc:
return None
doc_id = doc.get("_id")
if doc_id is None:
return None
return str(doc_id), doc
async def get_doc_by_content_hash(
self, content_hash: str
) -> Union[tuple[str, dict[str, Any]], None]:
"""Mongo-native override of content-hash document lookup.
Uses the partial ``content_hash`` index. Empty strings are treated as a
miss to align with the partial-index predicate; legacy rows missing the
field cannot match a non-empty query because ``find_one`` requires an
exact value.
"""
if not content_hash:
return None
try:
doc = await self._data.find_one({"content_hash": content_hash})
except PyMongoError as e:
logger.error(f"[{self.workspace}] Error in get_doc_by_content_hash: {e}")
return None
if not doc:
return None
doc_id = doc.get("_id")
if doc_id is None:
return None
return str(doc_id), doc
@final
@dataclass
class MongoGraphStorage(BaseGraphStorage):
"""
A concrete implementation using MongoDB's $graphLookup to demonstrate multi-hop queries.
"""
db: AsyncDatabase = field(default=None)
# node collection storing node_id, node_properties
collection: AsyncCollection = field(default=None)
# edge collection storing source_node_id, target_node_id, and edge_properties
edgeCollection: AsyncCollection = field(default=None)
def __init__(self, namespace, global_config, embedding_func, workspace=None):
super().__init__(
namespace=namespace,
workspace=workspace or "",
global_config=global_config,
embedding_func=embedding_func,
)
validate_workspace(self.workspace)
# Check for MONGODB_WORKSPACE environment variable first (higher priority)
# This allows administrators to force a specific workspace for all MongoDB storage instances
mongodb_workspace = os.environ.get("MONGODB_WORKSPACE")
if mongodb_workspace and mongodb_workspace.strip():
# Use environment variable value, overriding the passed workspace parameter
effective_workspace = mongodb_workspace.strip()
logger.info(
f"Using MONGODB_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}'"
)
# Build final_namespace with workspace prefix for data isolation
# Keep original namespace unchanged for type detection logic
if effective_workspace:
self.final_namespace = f"{effective_workspace}_{self.namespace}"
self.workspace = effective_workspace
logger.debug(
f"Final namespace with workspace prefix: '{self.final_namespace}'"
)
else:
# When workspace is empty, final_namespace equals original namespace
self.final_namespace = self.namespace
self.workspace = ""
logger.debug(f"Final namespace (no workspace): '{self.final_namespace}'")
self._collection_name = self.final_namespace
self._edge_collection_name = f"{self._collection_name}_edges"
(
self._max_upsert_payload_bytes,
self._max_upsert_records_per_batch,
) = _resolve_upsert_batch_limits()
self._max_delete_records_per_batch = _resolve_delete_batch_limit()
async def initialize(self):
async with get_data_init_lock():
if self.db is None:
self.db = await ClientManager.get_client()
self.collection = await get_or_create_collection(
self.db, self._collection_name
)
self.edge_collection = await get_or_create_collection(
self.db, self._edge_collection_name
)
# Create Atlas Search index for better search performance if possible
await self.create_search_index_if_not_exists()
# Fail-fast: migrate legacy edges to canonical endpoint fields and
# build the unique index before serving (upsert relies on it). Raises
# on failure so startup aborts rather than serving a half-migrated graph.
await self.create_edge_indexes_and_migrate_if_not_exists()
logger.debug(
f"[{self.workspace}] Use MongoDB as KG {self._collection_name}"
)
async def finalize(self):
if self.db is not None:
await ClientManager.release_client(self.db)
self.db = None
self.collection = None
self.edge_collection = None
async def create_edge_indexes_and_migrate_if_not_exists(self) -> None:
"""Create the compound unique edge-endpoint index, migrating legacy edges first.
Fail-fast one-time migration (mirrors the OpenSearch canonical-id work):
1. dedupe legacy reciprocal duplicate docs, **merging the full relation
payload** into the survivor (provenance unioned, keywords
set-unioned, descriptions joined, weight summed — like
``_merge_edges_then_upsert``) so no relation evidence is lost;
2. backfill the canonical ``edge_lo`` / ``edge_hi`` endpoints on every
remaining doc;
3. build the partial **compound** unique index on ``(edge_lo, edge_hi)``.
The endpoints are two separate fields (not a delimiter-joined string), so
distinct pairs never collide even if an id contains the would-be
delimiter — no input sanitisation required.
The index doubles as the completion flag: if it already exists we skip.
Anything failing raises, so ``initialize``/startup aborts rather than
serving a half-migrated collection (the upsert filter relies on every doc
having ``edge_lo``/``edge_hi``). Runs inside ``get_data_init_lock``, so
only the first worker of a deployment migrates; the rest skip on the index.
Assumes no concurrent *old-version* writer adds endpoint-less docs after
this completes (true for stop-the-world / single-deployment restarts). A
true rolling deploy with mixed code versions writing one collection could
leave a straggler duplicate; the remedy is to drop the
``edge_endpoints_unique`` index and let the next startup re-migrate.
"""
workspace_prefix = f"{self.workspace}_" if self.workspace != "" else ""
index_name = f"{workspace_prefix}edge_endpoints_unique"
indexes_cursor = await self.edge_collection.list_indexes()
existing_indexes = await indexes_cursor.to_list(length=None)
if any(idx.get("name") == index_name for idx in existing_indexes):
logger.info(
f"[{self.workspace}] Edge collection {self._edge_collection_name} "
f"already on canonical edge endpoints; skipping migration"
)
return
# Best-effort total for an X/total denominator (estimated_document_count
# is O(1) metadata); migration still works if it is unavailable.
try:
total = await self.edge_collection.estimated_document_count()
except PyMongoError:
total = None
logger.info(
f"[{self.workspace}] Starting canonical edge migration for "
f"{self._edge_collection_name}"
+ (f" (~{total} edges to scan)" if total is not None else "")
)
removed = await self._dedupe_legacy_edges()
backfilled = await self._backfill_edge_endpoints()
# The unique index is the completion flag — only created on full success.
# unique build raises if any duplicate slipped through (e.g. a concurrent
# old-version writer), which fails startup so the next run retries.
await self.edge_collection.create_index(
[("edge_lo", 1), ("edge_hi", 1)],
name=index_name,
unique=True,
partialFilterExpression={
"edge_lo": {"$exists": True, "$type": "string"},
"edge_hi": {"$exists": True, "$type": "string"},
},
)
scanned = total if total is not None else "?"
logger.info(
f"[{self.workspace}] Canonical edge migration complete for "
f"{self._edge_collection_name}: scanned {scanned}, deduped {removed}, "
f"backfilled {backfilled}"
)
async def _dedupe_legacy_edges(self) -> int:
"""Collapse duplicate docs for the same undirected edge into one.
Groups by the canonical (sorted) endpoint pair; for each group with more
than one doc, keeps the newest by ``created_at`` and **merges the
non-survivors' relation payload into it before deleting them** so no
relation evidence is lost: ``source_ids``/``source_id``/``file_path`` and
``description`` are unioned over their ``GRAPH_FIELD_SEP`` components,
``keywords`` are comma-set-unioned, and ``weight`` is **summed** (like
``_merge_edges_then_upsert`` — duplicate docs carry separate accumulated
weight).
The merge is **idempotent across retries**: if a transient error aborts
startup after the survivor update but before the delete, the next run
re-processes the same group and must produce the same survivor. The union
fields union their split components (re-merging an already-merged
survivor is a no-op), and the weight sum counts the survivor's current
weight once plus each other duplicate only while its source_ids are not
yet folded into the survivor — so a retry (whose survivor already
contains them) does not double-count. Returns the number of docs removed.
"""
pipeline = [
{
"$group": {
"_id": {
"lo": {
"$cond": [
{"$lte": ["$source_node_id", "$target_node_id"]},
"$source_node_id",
"$target_node_id",
]
},
"hi": {
"$cond": [
{"$lte": ["$source_node_id", "$target_node_id"]},
"$target_node_id",
"$source_node_id",
]
},
},
"docs": {
"$push": {
"_id": "$_id",
"source_id": "$source_id",
"source_ids": "$source_ids",
"file_path": "$file_path",
"description": "$description",
"keywords": "$keywords",
"weight": "$weight",
"created_at": "$created_at",
}
},
"count": {"$sum": 1},
}
},
{"$match": {"count": {"$gt": 1}}},
]
removed = 0
next_progress = _EDGE_MIGRATION_PROGRESS_INTERVAL
cursor = await self.edge_collection.aggregate(pipeline, allowDiskUse=True)
async for group in cursor:
docs = group["docs"]
survivor = max(docs, key=lambda d: d.get("created_at") or 0)
others = [d for d in docs if d["_id"] != survivor["_id"]]
if not others:
continue
# Merge the full relation payload across ALL docs (survivor included).
# The union fields (source_ids/file_path/description/keywords) union
# their split components, so re-merging an already-merged survivor (a
# fail-fast retry) is a no-op.
all_source_ids: list[str] = []
all_file_paths: list[str] = []
all_descriptions: list[str] = []
all_keywords: set[str] = set()
for d in docs:
all_source_ids = merge_source_ids(
all_source_ids, _edge_source_id_list(d)
)
fp = d.get("file_path")
all_file_paths = merge_source_ids(
all_file_paths, fp.split(GRAPH_FIELD_SEP) if fp else []
)
desc = d.get("description")
all_descriptions = merge_source_ids(
all_descriptions, desc.split(GRAPH_FIELD_SEP) if desc else []
)
kw = d.get("keywords")
if kw:
all_keywords.update(k.strip() for k in kw.split(",") if k.strip())
# Weight is summed like _merge_edges_then_upsert (duplicate docs carry
# separate accumulated evidence), but idempotently: the survivor's
# current weight is the base (counted once) and each other duplicate
# adds its weight ONLY if its source_ids are not already folded into
# the survivor. On a fail-fast retry the survivor already contains the
# others' source_ids, so they are skipped and the sum stays stable.
# Legacy string weights are coerced; non-numeric values are skipped so
# the migration cannot crash on a bad value.
survivor_sids = set(_edge_source_id_list(survivor))
weights: list[float] = []
sw = _coerce_weight(survivor.get("weight"))
if sw is not None:
weights.append(sw)
for o in others:
o_sids = set(_edge_source_id_list(o))
if not o_sids or o_sids <= survivor_sids:
continue # no new trackable evidence -> don't (re-)add weight
ow = _coerce_weight(o.get("weight"))
if ow is not None:
weights.append(ow)
set_fields: dict[str, Any] = {}
if all_source_ids:
set_fields["source_ids"] = all_source_ids
set_fields["source_id"] = GRAPH_FIELD_SEP.join(all_source_ids)
if all_file_paths:
set_fields["file_path"] = GRAPH_FIELD_SEP.join(all_file_paths)
if all_descriptions:
set_fields["description"] = GRAPH_FIELD_SEP.join(all_descriptions)
if all_keywords:
set_fields["keywords"] = ",".join(sorted(all_keywords))
if weights:
set_fields["weight"] = sum(weights)
if set_fields:
await self.edge_collection.update_one(
{"_id": survivor["_id"]}, {"$set": set_fields}
)
await self.edge_collection.delete_many(
{"_id": {"$in": [d["_id"] for d in others]}}
)
removed += len(others)
if removed >= next_progress:
logger.info(
f"[{self.workspace}] Canonical edge migration progress: "
f"deduped {removed} duplicate doc(s) so far"
)
next_progress += _EDGE_MIGRATION_PROGRESS_INTERVAL
return removed
async def _backfill_edge_endpoints(self) -> int:
"""Set the canonical ``edge_lo``/``edge_hi`` on every doc that lacks them.
Returns the modified count. Runs after dedupe, so each canonical pair has
one doc and the backfilled (edge_lo, edge_hi) pairs are unique.
"""
is_sorted = {"$lte": ["$source_node_id", "$target_node_id"]}
result = await self.edge_collection.update_many(
{"edge_lo": {"$exists": False}},
[
{
"$set": {
"edge_lo": {
"$cond": [
is_sorted,
"$source_node_id",
"$target_node_id",
]
},
"edge_hi": {
"$cond": [
is_sorted,
"$target_node_id",
"$source_node_id",
]
},
}
}
],
)
return result.modified_count
# Sample entity document
# "source_ids" is Array representation of "source_id" split by GRAPH_FIELD_SEP
# {
# "_id" : "CompanyA",
# "entity_id" : "CompanyA",
# "entity_type" : "Organization",
# "description" : "A major technology company",
# "source_id" : "chunk-eeec0036b909839e8ec4fa150c939eec",
# "source_ids": ["chunk-eeec0036b909839e8ec4fa150c939eec"],
# "file_path" : "custom_kg",
# "created_at" : 1749904575
# }
# Sample relation document
# {
# "_id" : ObjectId("6856ac6e7c6bad9b5470b678"), // MongoDB build-in ObjectId
# "description" : "CompanyA develops ProductX",
# "source_node_id" : "CompanyA",
# "target_node_id" : "ProductX",
# "relationship": "Develops", // To distinguish multiple same-target relations
# "weight" : Double("1"),
# "keywords" : "develop, produce",
# "source_id" : "chunk-eeec0036b909839e8ec4fa150c939eec",
# "source_ids": ["chunk-eeec0036b909839e8ec4fa150c939eec"],
# "file_path" : "custom_kg",
# "created_at" : 1749904575
# }
#
# -------------------------------------------------------------------------
# BASIC QUERIES
# -------------------------------------------------------------------------
#
async def has_node(self, node_id: str) -> bool:
"""
Check if node_id is present in the collection by looking up its doc.
No real need for $graphLookup here, but let's keep it direct.
"""
doc = await self.collection.find_one({"_id": node_id}, {"_id": 1})
return doc is not None
async def has_edge(self, source_node_id: str, target_node_id: str) -> bool:
"""
Check if there's a direct single-hop edge between source_node_id and target_node_id.
Matches on the canonical ``(edge_lo, edge_hi)`` pair (direction-independent)
instead of the bidirectional ``$or``, so this point lookup is served by the
compound unique index. Safe because the fail-fast migration in
``initialize`` guarantees every served doc carries the endpoints.
"""
edge_lo, edge_hi = _canonical_edge_endpoints(source_node_id, target_node_id)
doc = await self.edge_collection.find_one(
{"edge_lo": edge_lo, "edge_hi": edge_hi},
{"_id": 1},
)
return doc is not None
#
# -------------------------------------------------------------------------
# DEGREES
# -------------------------------------------------------------------------
#
async def node_degree(self, node_id: str) -> int:
"""
Returns the total number of edges connected to node_id (both inbound and outbound).
"""
return await self.edge_collection.count_documents(
{"$or": [{"source_node_id": node_id}, {"target_node_id": node_id}]}
)
async def edge_degree(self, src_id: str, tgt_id: str) -> int:
"""Get the total degree (sum of relationships) of two nodes.
Args:
src_id: Label of the source node
tgt_id: Label of the target node
Returns:
int: Sum of the degrees of both nodes
"""
src_degree = await self.node_degree(src_id)
trg_degree = await self.node_degree(tgt_id)
return src_degree + trg_degree
#
# -------------------------------------------------------------------------
# GETTERS
# -------------------------------------------------------------------------
#
async def get_node(self, node_id: str) -> dict[str, str] | None:
"""
Return the node properties, or None if missing.
The Mongo-managed ``_id`` (which holds the entity name) is stripped so
the returned dict carries only node properties, matching the contract
honored by the other backends. Leaving it in lets callers that re-upsert
a fetched node (e.g. entity rename) push ``_id`` into ``$set``, which
MongoDB rejects as a modification of the immutable ``_id``.
"""
doc = await self.collection.find_one({"_id": node_id})
if doc is not None:
doc.pop("_id", None)
return doc
async def get_edge(
self, source_node_id: str, target_node_id: str
) -> dict[str, str] | None:
# Canonical (edge_lo, edge_hi) point lookup served by the compound unique
# index (see has_edge); the fail-fast migration guarantees the endpoints.
edge_lo, edge_hi = _canonical_edge_endpoints(source_node_id, target_node_id)
doc = await self.edge_collection.find_one(
{"edge_lo": edge_lo, "edge_hi": edge_hi}
)
if doc is not None:
# Strip the Mongo-managed ``_id`` so re-upserting a fetched edge
# (e.g. relation rewrite during entity rename) cannot push ``_id``
# into ``$set`` and trip the immutable-field error.
doc.pop("_id", None)
return doc
async def get_node_edges(self, source_node_id: str) -> list[tuple[str, str]] | None:
"""
Retrieves all edges (relationships) for a particular node identified by its label.
Args:
source_node_id: Label of the node to get edges for
Returns:
list[tuple[str, str]]: List of (source_label, target_label) tuples representing edges
None: If no edges found
"""
cursor = self.edge_collection.find(
{
"$or": [
{"source_node_id": source_node_id},
{"target_node_id": source_node_id},
]
},
{"source_node_id": 1, "target_node_id": 1},
)
return [
(e.get("source_node_id"), e.get("target_node_id")) async for e in cursor
]
async def get_nodes_batch(self, node_ids: list[str]) -> dict[str, dict]:
result = {}
async for doc in self.collection.find({"_id": {"$in": node_ids}}):
node_id = doc.pop("_id")
result[node_id] = doc
return result
async def node_degrees_batch(self, node_ids: list[str]) -> dict[str, int]:
# merge the outbound and inbound results with the same "_id" and sum the "degree"
merged_results = {}
# Outbound degrees
outbound_pipeline = [
{"$match": {"source_node_id": {"$in": node_ids}}},
{"$group": {"_id": "$source_node_id", "degree": {"$sum": 1}}},
]
cursor = await self.edge_collection.aggregate(
outbound_pipeline, allowDiskUse=True
)
async for doc in cursor:
merged_results[doc.get("_id")] = doc.get("degree")
# Inbound degrees
inbound_pipeline = [
{"$match": {"target_node_id": {"$in": node_ids}}},
{"$group": {"_id": "$target_node_id", "degree": {"$sum": 1}}},
]
cursor = await self.edge_collection.aggregate(
inbound_pipeline, allowDiskUse=True
)
async for doc in cursor:
merged_results[doc.get("_id")] = merged_results.get(
doc.get("_id"), 0
) + doc.get("degree")
return merged_results
async def get_nodes_edges_batch(
self, node_ids: list[str]
) -> dict[str, list[tuple[str, str]]]:
"""
Batch retrieve edges for multiple nodes.
For each node, returns both outgoing and incoming edges to properly represent
the undirected graph nature.
Args:
node_ids: List of node IDs (entity_id) for which to retrieve edges.
Returns:
A dictionary mapping each node ID to its list of edge tuples (source, target).
For each node, the list includes both:
- Outgoing edges: (queried_node, connected_node)
- Incoming edges: (connected_node, queried_node)
"""
result = {node_id: [] for node_id in node_ids}
# Query outgoing edges (where node is the source)
outgoing_cursor = self.edge_collection.find(
{"source_node_id": {"$in": node_ids}},
{"source_node_id": 1, "target_node_id": 1},
)
async for edge in outgoing_cursor:
source = edge["source_node_id"]
target = edge["target_node_id"]
result[source].append((source, target))
# Query incoming edges (where node is the target)
incoming_cursor = self.edge_collection.find(
{"target_node_id": {"$in": node_ids}},
{"source_node_id": 1, "target_node_id": 1},
)
async for edge in incoming_cursor:
source = edge["source_node_id"]
target = edge["target_node_id"]
result[target].append((source, target))
return result
#
# -------------------------------------------------------------------------
# UPSERTS
# -------------------------------------------------------------------------
#
async def upsert_node(self, node_id: str, node_data: dict[str, str]) -> None:
"""
Insert or update a node document.
"""
update_doc = {"$set": {**node_data}}
if node_data.get("source_id", ""):
update_doc["$set"]["source_ids"] = node_data["source_id"].split(
GRAPH_FIELD_SEP
)
await self.collection.update_one({"_id": node_id}, update_doc, upsert=True)
async def upsert_edge(
self, source_node_id: str, target_node_id: str, edge_data: dict[str, str]
) -> None:
"""Upsert the undirected edge between source_node_id and target_node_id.
Matches on the canonical ``(edge_lo, edge_hi)`` endpoint pair
(direction-independent) instead of the old bidirectional ``$or`` filter,
so the compound unique index can reject a racing duplicate insert. If two
writers race the first insert, the loser hits a ``DuplicateKeyError``; we
retry once, which now matches the just-inserted doc and updates it.
"""
# Ensure source node exists
await self.upsert_node(source_node_id, {})
edge_lo, edge_hi = _canonical_edge_endpoints(source_node_id, target_node_id)
# Copy so we never mutate the caller's edge_data dict.
set_doc: dict = {**edge_data}
if edge_data.get("source_id", ""):
set_doc["source_ids"] = edge_data["source_id"].split(GRAPH_FIELD_SEP)
set_doc["source_node_id"] = source_node_id
set_doc["target_node_id"] = target_node_id
set_doc["edge_lo"] = edge_lo
set_doc["edge_hi"] = edge_hi
update_doc = {"$set": set_doc}
for attempt in range(2):
try:
await self.edge_collection.update_one(
{"edge_lo": edge_lo, "edge_hi": edge_hi}, update_doc, upsert=True
)
return
except DuplicateKeyError:
# Another writer inserted this edge between our filter miss and
# insert. Retry once: the doc now exists, so the upsert becomes a
# plain update. A second failure is unexpected — let it surface.
if attempt == 1:
raise
async def upsert_nodes_batch(self, nodes: list[tuple[str, dict[str, str]]]) -> None:
"""Batch insert/update multiple nodes using a single bulk_write() call.
Args:
nodes: List of (node_id, node_data) tuples.
"""
if not nodes:
return
ops: list[tuple[Any, int, str]] = []
for node_id, node_data in nodes:
update_doc: dict = {"$set": {**node_data}}
if node_data.get("source_id", ""):
update_doc["$set"]["source_ids"] = node_data["source_id"].split(
GRAPH_FIELD_SEP
)
ops.append(
(
UpdateOne({"_id": node_id}, update_doc, upsert=True),
_estimate_doc_bytes(update_doc),
node_id,
)
)
await _run_batched_bulk_write(
self.collection,
ops,
max_payload_bytes=self._max_upsert_payload_bytes,
max_records_per_batch=self._max_upsert_records_per_batch,
ordered=True,
log_prefix=f"[{self.workspace}] {self.namespace} nodes:",
what="node upsert",
)
async def has_nodes_batch(self, node_ids: list[str]) -> set[str]:
"""Check existence of multiple nodes using a single $in query.
Args:
node_ids: List of node IDs to check.
Returns:
Set of node_ids that exist in the graph.
"""
if not node_ids:
return set()
cursor = self.collection.find({"_id": {"$in": node_ids}}, {"_id": 1})
return {doc["_id"] async for doc in cursor}
async def upsert_edges_batch(
self, edges: list[tuple[str, str, dict[str, str]]]
) -> None:
"""Batch insert/update multiple edges using a single bulk_write() call.
Also ensures source nodes exist (matching upsert_edge() behaviour) via a
separate bulk_write on the node collection for any source nodes that need
to be created as empty placeholders.
Args:
edges: List of (source_node_id, target_node_id, edge_data) tuples.
"""
if not edges:
return
# Ensure all source nodes exist (mirrors upsert_edge's upsert_node call)
source_node_ids = list(dict.fromkeys(src for src, _tgt, _data in edges))
node_ops: list[tuple[Any, int, str]] = [
(
UpdateOne({"_id": src}, {"$setOnInsert": {"_id": src}}, upsert=True),
_estimate_doc_bytes({"_id": src}),
src,
)
for src in source_node_ids
]
await _run_batched_bulk_write(
self.collection,
node_ops,
max_payload_bytes=self._max_upsert_payload_bytes,
max_records_per_batch=self._max_upsert_records_per_batch,
ordered=False,
log_prefix=f"[{self.workspace}] {self.namespace} edges:",
what="source-node placeholder upsert",
)
# Key every edge by its canonical (edge_lo, edge_hi) pair and dedupe
# within the batch (last-write-wins). Deduping collapses reciprocal
# directions onto one op, which both matches the compound unique index
# and avoids an intra-batch duplicate-key error from two ops inserting
# the same endpoint pair.
deduped_ops: dict[tuple[str, str], tuple[Any, int, str]] = {}
for source_node_id, target_node_id, edge_data in edges:
update_doc: dict = {"$set": {**edge_data}}
if edge_data.get("source_id", ""):
update_doc["$set"]["source_ids"] = edge_data["source_id"].split(
GRAPH_FIELD_SEP
)
update_doc["$set"]["source_node_id"] = source_node_id
update_doc["$set"]["target_node_id"] = target_node_id
edge_lo, edge_hi = _canonical_edge_endpoints(source_node_id, target_node_id)
update_doc["$set"]["edge_lo"] = edge_lo
update_doc["$set"]["edge_hi"] = edge_hi
deduped_ops[(edge_lo, edge_hi)] = (
UpdateOne(
{"edge_lo": edge_lo, "edge_hi": edge_hi}, update_doc, upsert=True
),
_estimate_doc_bytes(update_doc),
f"{source_node_id}->{target_node_id}",
)
edge_ops = list(deduped_ops.values())
# ordered=True (kept from the pre-canonical behaviour). Intra-batch
# last-write-wins is already guaranteed by the endpoint-pair dedupe above
# (one op per pair), so ordering is not load-bearing for that; we keep it
# for continuity. If a concurrent writer (another process bypassing the keyed
# lock) wins an insert, our upsert hits 11000 and the bulk aborts; we
# retry the whole op list once — the racing docs now exist, so the
# upserts update instead of inserting (idempotent). A non-11000 / write-
# concern error re-raises rather than being masked.
async def _run_edge_bulk() -> None:
await _run_batched_bulk_write(
self.edge_collection,
edge_ops,
max_payload_bytes=self._max_upsert_payload_bytes,
max_records_per_batch=self._max_upsert_records_per_batch,
ordered=True,
log_prefix=f"[{self.workspace}] {self.namespace} edges:",
what="edge upsert",
)
try:
await _run_edge_bulk()
except BulkWriteError as e:
details = e.details or {}
write_errors = details.get("writeErrors", [])
# Retry ONLY when every failure is a duplicate-key race; a
# writeConcern failure (durability problem, empty writeErrors) or any
# other write error must surface, not be masked by a blind retry.
#
# NOTE: under ordered=True the bulk aborts at the FIRST failing op, so
# writeErrors holds at most one entry — the all(...) check therefore
# only inspects that first error, not the whole batch. Ops after it
# never ran; they re-run when we retry the entire op list below. So a
# non-11000 error hidden behind a leading 11000 is not masked — it
# simply surfaces one retry later (the retry hits it and re-raises,
# since by then the leading dup has resolved to a plain update).
dup_only = (
bool(write_errors)
and all(we.get("code") == _DUPLICATE_KEY_CODE for we in write_errors)
and not details.get("writeConcernErrors")
)
if not dup_only:
raise
logger.debug(
f"[{self.workspace}] {self.namespace} edges: {len(write_errors)} "
f"duplicate-key race(s) on edge upsert; retrying as updates"
)
await _run_edge_bulk()
#
# -------------------------------------------------------------------------
# DELETION
# -------------------------------------------------------------------------
#
async def delete_node(self, node_id: str) -> None:
"""
1) Remove node's doc entirely.
2) Remove inbound & outbound edges from any doc that references node_id.
"""
# Remove all edges
await self.edge_collection.delete_many(
{"$or": [{"source_node_id": node_id}, {"target_node_id": node_id}]}
)
# Remove the node doc
await self.collection.delete_one({"_id": node_id})
#
# -------------------------------------------------------------------------
# QUERY
# -------------------------------------------------------------------------
#
async def get_all_labels(self) -> list[str]:
"""
Get all existing node _ids(entity names) in the database
Returns:
[id1, id2, ...] # Alphabetically sorted id list
"""
# Use aggregation with allowDiskUse for large datasets
pipeline = [{"$project": {"_id": 1}}, {"$sort": {"_id": 1}}]
cursor = await self.collection.aggregate(pipeline, allowDiskUse=True)
labels = []
async for doc in cursor:
labels.append(doc["_id"])
return labels
def _construct_graph_node(
self, node_id, node_data: dict[str, str]
) -> KnowledgeGraphNode:
return KnowledgeGraphNode(
id=node_id,
labels=[node_id],
properties={
k: v
for k, v in node_data.items()
if k
not in [
"_id",
"connected_edges",
"source_ids",
"edge_count",
]
},
)
def _construct_graph_edge(self, edge_id: str, edge: dict[str, str]):
return KnowledgeGraphEdge(
id=edge_id,
type=edge.get("relationship", ""),
source=edge["source_node_id"],
target=edge["target_node_id"],
properties={
k: v
for k, v in edge.items()
if k
not in [
"_id",
"source_node_id",
"target_node_id",
"relationship",
"source_ids",
"edge_lo",
"edge_hi",
]
},
)
async def _fetch_nodes_by_ids(
self, node_ids: list[str], projection: dict[str, int] | None = None
) -> list[dict[str, Any]]:
"""Fetch nodes by ID while preserving the requested order."""
if not node_ids:
return []
cursor = self.collection.find({"_id": {"$in": node_ids}}, projection)
docs_by_id = {}
async for doc in cursor:
docs_by_id[str(doc["_id"])] = doc
return [docs_by_id[node_id] for node_id in node_ids if node_id in docs_by_id]
async def get_knowledge_graph_all_by_degree(
self, max_depth: int, max_nodes: int
) -> KnowledgeGraph:
"""
It's possible that the node with one or multiple relationships is retrieved,
while its neighbor is not. Then this node might seem like disconnected in UI.
"""
total_node_count = await self.collection.count_documents({})
result = KnowledgeGraph()
seen_edges = set()
result.is_truncated = total_node_count > max_nodes
if result.is_truncated:
# Get all node_ids ranked by degree if max_nodes exceeds total node count
pipeline = [
{"$project": {"source_node_id": 1, "_id": 0}},
{"$group": {"_id": "$source_node_id", "degree": {"$sum": 1}}},
{
"$unionWith": {
"coll": self._edge_collection_name,
"pipeline": [
{"$project": {"target_node_id": 1, "_id": 0}},
{
"$group": {
"_id": "$target_node_id",
"degree": {"$sum": 1},
}
},
],
}
},
{"$group": {"_id": "$_id", "degree": {"$sum": "$degree"}}},
{"$sort": {"degree": -1}},
{"$limit": max_nodes},
]
cursor = await self.edge_collection.aggregate(pipeline, allowDiskUse=True)
node_ids = []
async for doc in cursor:
node_id = str(doc["_id"])
node_ids.append(node_id)
if len(node_ids) < max_nodes:
remaining = max_nodes - len(node_ids)
cursor = self.collection.find(
{"_id": {"$nin": node_ids}},
{"source_ids": 0},
).limit(remaining)
async for doc in cursor:
node_ids.append(str(doc["_id"]))
docs = await self._fetch_nodes_by_ids(node_ids, {"source_ids": 0})
for doc in docs:
result.nodes.append(self._construct_graph_node(doc["_id"], doc))
# As node count reaches the limit, only need to fetch the edges that directly connect to these nodes
edge_cursor = self.edge_collection.find(
{
"$and": [
{"source_node_id": {"$in": node_ids}},
{"target_node_id": {"$in": node_ids}},
]
}
)
else:
# All nodes and edges are needed
cursor = self.collection.find({}, {"source_ids": 0})
async for doc in cursor:
node_id = str(doc["_id"])
result.nodes.append(self._construct_graph_node(doc["_id"], doc))
edge_cursor = self.edge_collection.find({})
async for edge in edge_cursor:
edge_id = f"{edge['source_node_id']}-{edge['target_node_id']}"
if edge_id not in seen_edges:
seen_edges.add(edge_id)
result.edges.append(self._construct_graph_edge(edge_id, edge))
return result
async def _bidirectional_bfs_nodes(
self,
node_labels: list[str],
seen_nodes: set[str],
result: KnowledgeGraph,
depth: int,
max_depth: int,
max_nodes: int,
) -> KnowledgeGraph:
if depth > max_depth or len(result.nodes) > max_nodes:
return result
cursor = self.collection.find({"_id": {"$in": node_labels}})
async for node in cursor:
node_id = node["_id"]
if node_id not in seen_nodes:
seen_nodes.add(node_id)
result.nodes.append(self._construct_graph_node(node_id, node))
if len(result.nodes) > max_nodes:
return result
# Collect neighbors
# Get both inbound and outbound one hop nodes
cursor = self.edge_collection.find(
{
"$or": [
{"source_node_id": {"$in": node_labels}},
{"target_node_id": {"$in": node_labels}},
]
}
)
neighbor_nodes = []
async for edge in cursor:
if edge["source_node_id"] not in seen_nodes:
neighbor_nodes.append(edge["source_node_id"])
if edge["target_node_id"] not in seen_nodes:
neighbor_nodes.append(edge["target_node_id"])
if neighbor_nodes:
result = await self._bidirectional_bfs_nodes(
neighbor_nodes, seen_nodes, result, depth + 1, max_depth, max_nodes
)
return result
async def get_knowledge_subgraph_bidirectional_bfs(
self,
node_label: str,
depth: int,
max_depth: int,
max_nodes: int,
) -> KnowledgeGraph:
seen_nodes = set()
seen_edges = set()
result = KnowledgeGraph()
result = await self._bidirectional_bfs_nodes(
[node_label], seen_nodes, result, depth, max_depth, max_nodes
)
# Get all edges from seen_nodes
all_node_ids = list(seen_nodes)
cursor = self.edge_collection.find(
{
"$and": [
{"source_node_id": {"$in": all_node_ids}},
{"target_node_id": {"$in": all_node_ids}},
]
}
)
async for edge in cursor:
edge_id = f"{edge['source_node_id']}-{edge['target_node_id']}"
if edge_id not in seen_edges:
result.edges.append(self._construct_graph_edge(edge_id, edge))
seen_edges.add(edge_id)
return result
async def get_knowledge_subgraph_in_out_bound_bfs(
self, node_label: str, max_depth: int, max_nodes: int
) -> KnowledgeGraph:
seen_nodes = set()
seen_edges = set()
result = KnowledgeGraph()
project_doc = {
"source_ids": 0,
"created_at": 0,
"entity_type": 0,
"file_path": 0,
}
# Verify if starting node exists
start_node = await self.collection.find_one({"_id": node_label})
if not start_node:
logger.warning(
f"[{self.workspace}] Starting node with label {node_label} does not exist!"
)
return result
seen_nodes.add(node_label)
result.nodes.append(self._construct_graph_node(node_label, start_node))
if max_depth == 0:
return result
# In MongoDB, depth = 0 means one-hop
max_depth = max_depth - 1
pipeline = [
{"$match": {"_id": node_label}},
{"$project": project_doc},
{
"$graphLookup": {
"from": self._edge_collection_name,
"startWith": "$_id",
"connectFromField": "target_node_id",
"connectToField": "source_node_id",
"maxDepth": max_depth,
"depthField": "depth",
"as": "connected_edges",
},
},
{
"$unionWith": {
"coll": self._collection_name,
"pipeline": [
{"$match": {"_id": node_label}},
{"$project": project_doc},
{
"$graphLookup": {
"from": self._edge_collection_name,
"startWith": "$_id",
"connectFromField": "source_node_id",
"connectToField": "target_node_id",
"maxDepth": max_depth,
"depthField": "depth",
"as": "connected_edges",
}
},
],
}
},
]
cursor = await self.collection.aggregate(pipeline, allowDiskUse=True)
node_edges = []
# Two records for node_label are returned capturing outbound and inbound connected_edges
async for doc in cursor:
if doc.get("connected_edges", []):
node_edges.extend(doc.get("connected_edges"))
# Sort the connected edges by depth ascending and weight descending
# And stores the source_node_id and target_node_id in sequence to retrieve the neighbouring nodes
node_edges = sorted(
node_edges,
key=lambda x: (x["depth"], -x["weight"]),
)
# As order matters, we need to use another list to store the node_id
# And only take the first max_nodes ones
node_ids = []
for edge in node_edges:
if len(node_ids) < max_nodes and edge["source_node_id"] not in seen_nodes:
node_ids.append(edge["source_node_id"])
seen_nodes.add(edge["source_node_id"])
if len(node_ids) < max_nodes and edge["target_node_id"] not in seen_nodes:
node_ids.append(edge["target_node_id"])
seen_nodes.add(edge["target_node_id"])
# Filter out all the node whose id is same as node_label so that we do not check existence next step
cursor = self.collection.find({"_id": {"$in": node_ids}})
async for doc in cursor:
result.nodes.append(self._construct_graph_node(str(doc["_id"]), doc))
for edge in node_edges:
if (
edge["source_node_id"] not in seen_nodes
or edge["target_node_id"] not in seen_nodes
):
continue
edge_id = f"{edge['source_node_id']}-{edge['target_node_id']}"
if edge_id not in seen_edges:
result.edges.append(self._construct_graph_edge(edge_id, edge))
seen_edges.add(edge_id)
return result
async def get_knowledge_graph(
self,
node_label: str,
max_depth: int = 3,
max_nodes: int = None,
) -> KnowledgeGraph:
"""
Retrieve a connected subgraph of nodes where the label includes the specified `node_label`.
Args:
node_label: Label of the starting node, * means all nodes
max_depth: Maximum depth of the subgraph, Defaults to 3
max_nodes: Maximum nodes to return, Defaults to global_config max_graph_nodes
Returns:
KnowledgeGraph object containing nodes and edges, with an is_truncated flag
indicating whether the graph was truncated due to max_nodes limit
If a graph is like this and starting from B:
A → B ← C ← F, B -> E, C → D
Outbound BFS:
B → E
Inbound BFS:
A → B
C → B
F → C
Bidirectional BFS:
A → B
B → E
F → C
C → B
C → D
"""
# Use global_config max_graph_nodes as default if max_nodes is None
if max_nodes is None:
max_nodes = self.global_config.get("max_graph_nodes", 1000)
else:
# Limit max_nodes to not exceed global_config max_graph_nodes
max_nodes = min(max_nodes, self.global_config.get("max_graph_nodes", 1000))
result = KnowledgeGraph()
start = time.perf_counter()
try:
# Optimize pipeline to avoid memory issues with large datasets
if node_label == "*":
result = await self.get_knowledge_graph_all_by_degree(
max_depth, max_nodes
)
elif GRAPH_BFS_MODE == "in_out_bound":
result = await self.get_knowledge_subgraph_in_out_bound_bfs(
node_label, max_depth, max_nodes
)
else:
result = await self.get_knowledge_subgraph_bidirectional_bfs(
node_label, 0, max_depth, max_nodes
)
duration = time.perf_counter() - start
logger.info(
f"[{self.workspace}] Subgraph query successful in {duration:.4f} seconds | Node count: {len(result.nodes)} | Edge count: {len(result.edges)} | Truncated: {result.is_truncated}"
)
except PyMongoError as e:
# Handle memory limit errors specifically
if "memory limit" in str(e).lower() or "sort exceeded" in str(e).lower():
logger.warning(
f"[{self.workspace}] MongoDB memory limit exceeded, falling back to simple query: {str(e)}"
)
# Fallback to a simple query without complex aggregation
try:
simple_cursor = self.collection.find({}).limit(max_nodes)
async for doc in simple_cursor:
result.nodes.append(
self._construct_graph_node(str(doc["_id"]), doc)
)
result.is_truncated = True
logger.info(
f"[{self.workspace}] Fallback query completed | Node count: {len(result.nodes)}"
)
except PyMongoError as fallback_error:
logger.error(
f"[{self.workspace}] Fallback query also failed: {str(fallback_error)}"
)
else:
logger.error(f"[{self.workspace}] MongoDB query failed: {str(e)}")
return result
async def index_done_callback(self) -> None:
# Mongo handles persistence automatically
pass
async def remove_nodes(self, nodes: list[str]) -> None:
"""Delete multiple nodes
Args:
nodes: List of node IDs to be deleted
"""
logger.info(f"[{self.workspace}] Deleting {len(nodes)} nodes")
if not nodes:
return
# 1. Remove all edges referencing these nodes
await self.edge_collection.delete_many(
{
"$or": [
{"source_node_id": {"$in": nodes}},
{"target_node_id": {"$in": nodes}},
]
}
)
# 2. Delete the node documents
await self.collection.delete_many({"_id": {"$in": nodes}})
logger.debug(f"[{self.workspace}] Successfully deleted nodes: {nodes}")
async def remove_edges(self, edges: list[tuple[str, str]]) -> None:
"""Delete multiple edges
Args:
edges: List of edges to be deleted, each edge is a (source, target) tuple
"""
logger.info(f"[{self.workspace}] Deleting {len(edges)} edges")
if not edges:
return
# Match each edge by its canonical (edge_lo, edge_hi) pair: one clause per
# edge (vs. the old two-clause bidirectional pair) served by the compound
# unique index, with reciprocal/duplicate inputs collapsed. Safe because
# the fail-fast migration guarantees every served doc carries the endpoints.
seen: set[tuple[str, str]] = set()
all_edge_pairs = []
for source_id, target_id in edges:
endpoints = _canonical_edge_endpoints(source_id, target_id)
if endpoints in seen:
continue
seen.add(endpoints)
all_edge_pairs.append({"edge_lo": endpoints[0], "edge_hi": endpoints[1]})
# Chunk the $or by record count so a large delete stays under the bulk
# message / 16MB query limit; endpoints are bounded id strings, so a count
# cap is enough (no byte budget needed). A non-positive cap disables it.
chunk = (
self._max_delete_records_per_batch
if self._max_delete_records_per_batch > 0
else len(all_edge_pairs)
)
for i in range(0, len(all_edge_pairs), chunk):
await self.edge_collection.delete_many(
{"$or": all_edge_pairs[i : i + chunk]}
)
logger.debug(f"[{self.workspace}] Successfully deleted edges: {edges}")
async def get_all_nodes(self) -> list[dict]:
"""Get all nodes in the graph.
Returns:
A list of all nodes, where each node is a dictionary of its properties
"""
cursor = self.collection.find({})
nodes = []
async for node in cursor:
node_dict = dict(node)
# Add node id (entity_id) to the dictionary for easier access
node_dict["id"] = node_dict.get("_id")
nodes.append(node_dict)
return nodes
async def get_all_edges(self) -> list[dict]:
"""Get all edges in the graph.
Returns:
A list of all edges, where each edge is a dictionary of its properties
"""
cursor = self.edge_collection.find({})
edges = []
async for edge in cursor:
edge_dict = dict(edge)
edge_dict["source"] = edge_dict.get("source_node_id")
edge_dict["target"] = edge_dict.get("target_node_id")
edges.append(edge_dict)
return edges
async def get_popular_labels(self, limit: int = 300) -> list[str]:
"""Get popular labels(entity names) by node degree (most connected entities)
Args:
limit: Maximum number of labels to return
Returns:
List of labels(entity names) sorted by degree (highest first)
"""
try:
# Use aggregation pipeline to count edges per node and sort by degree
pipeline = [
# Count outbound edges
{"$group": {"_id": "$source_node_id", "out_degree": {"$sum": 1}}},
# Union with inbound edges count
{
"$unionWith": {
"coll": self._edge_collection_name,
"pipeline": [
{
"$group": {
"_id": "$target_node_id",
"in_degree": {"$sum": 1},
}
}
],
}
},
# Group by node_id and sum degrees
{
"$group": {
"_id": "$_id",
"total_degree": {
"$sum": {
"$add": [
{"$ifNull": ["$out_degree", 0]},
{"$ifNull": ["$in_degree", 0]},
]
}
},
}
},
# Sort by degree descending, then by label ascending
{"$sort": {"total_degree": -1, "_id": 1}},
# Limit results
{"$limit": limit},
# Project only the label
{"$project": {"_id": 1}},
]
cursor = await self.edge_collection.aggregate(pipeline, allowDiskUse=True)
labels = []
async for doc in cursor:
if doc.get("_id"):
labels.append(doc["_id"])
logger.debug(
f"[{self.workspace}] Retrieved {len(labels)} popular labels (limit: {limit})"
)
return labels
except Exception as e:
logger.error(f"[{self.workspace}] Error getting popular labels: {str(e)}")
return []
async def _try_atlas_text_search(self, query_strip: str, limit: int) -> list[str]:
"""Try Atlas Search using simple text search."""
try:
pipeline = [
{
"$search": {
"index": "entity_id_search_idx",
"text": {"query": query_strip, "path": "_id"},
}
},
{"$project": {"_id": 1, "score": {"$meta": "searchScore"}}},
{"$limit": limit},
]
cursor = await self.collection.aggregate(pipeline)
labels = [doc["_id"] async for doc in cursor if doc.get("_id")]
if labels:
logger.debug(
f"[{self.workspace}] Atlas text search returned {len(labels)} results"
)
return labels
return []
except PyMongoError as e:
logger.debug(f"[{self.workspace}] Atlas text search failed: {e}")
return []
async def _try_atlas_autocomplete_search(
self, query_strip: str, limit: int
) -> list[str]:
"""Try Atlas Search using autocomplete for prefix matching."""
try:
pipeline = [
{
"$search": {
"index": "entity_id_search_idx",
"autocomplete": {
"query": query_strip,
"path": "_id",
"fuzzy": {"maxEdits": 1, "prefixLength": 1},
},
}
},
{"$project": {"_id": 1, "score": {"$meta": "searchScore"}}},
{"$limit": limit},
]
cursor = await self.collection.aggregate(pipeline)
labels = [doc["_id"] async for doc in cursor if doc.get("_id")]
if labels:
logger.debug(
f"[{self.workspace}] Atlas autocomplete search returned {len(labels)} results"
)
return labels
return []
except PyMongoError as e:
logger.debug(f"[{self.workspace}] Atlas autocomplete search failed: {e}")
return []
async def _try_atlas_compound_search(
self, query_strip: str, limit: int
) -> list[str]:
"""Try Atlas Search using compound query for comprehensive matching."""
try:
pipeline = [
{
"$search": {
"index": "entity_id_search_idx",
"compound": {
"should": [
{
"text": {
"query": query_strip,
"path": "_id",
"score": {"boost": {"value": 10}},
}
},
{
"autocomplete": {
"query": query_strip,
"path": "_id",
"score": {"boost": {"value": 5}},
"fuzzy": {"maxEdits": 1, "prefixLength": 1},
}
},
{
"wildcard": {
"query": f"*{query_strip}*",
"path": "_id",
"score": {"boost": {"value": 2}},
}
},
],
"minimumShouldMatch": 1,
},
}
},
{"$project": {"_id": 1, "score": {"$meta": "searchScore"}}},
{"$sort": {"score": {"$meta": "searchScore"}}},
{"$limit": limit},
]
cursor = await self.collection.aggregate(pipeline)
labels = [doc["_id"] async for doc in cursor if doc.get("_id")]
if labels:
logger.debug(
f"[{self.workspace}] Atlas compound search returned {len(labels)} results"
)
return labels
return []
except PyMongoError as e:
logger.debug(f"[{self.workspace}] Atlas compound search failed: {e}")
return []
async def _fallback_regex_search(self, query_strip: str, limit: int) -> list[str]:
"""Fallback to regex-based search when Atlas Search fails."""
try:
logger.debug(
f"[{self.workspace}] Using regex fallback search for: '{query_strip}'"
)
escaped_query = re.escape(query_strip)
regex_condition = {"_id": {"$regex": escaped_query, "$options": "i"}}
cursor = self.collection.find(regex_condition, {"_id": 1}).limit(limit * 2)
docs = await cursor.to_list(length=limit * 2)
# Extract labels
labels = []
for doc in docs:
doc_id = doc.get("_id")
if doc_id:
labels.append(doc_id)
# Sort results to prioritize exact matches and starts-with matches
def sort_key(label):
label_lower = label.lower()
query_lower_strip = query_strip.lower()
if label_lower == query_lower_strip:
return (0, label_lower) # Exact match - highest priority
elif label_lower.startswith(query_lower_strip):
return (1, label_lower) # Starts with - medium priority
else:
return (2, label_lower) # Contains - lowest priority
labels.sort(key=sort_key)
labels = labels[:limit] # Apply final limit after sorting
logger.debug(
f"[{self.workspace}] Regex fallback search returned {len(labels)} results (limit: {limit})"
)
return labels
except Exception as e:
logger.error(f"[{self.workspace}] Regex fallback search failed: {e}")
import traceback
logger.error(f"[{self.workspace}] Traceback: {traceback.format_exc()}")
return []
async def search_labels(self, query: str, limit: int = 50) -> list[str]:
"""
Search labels(entity names) with progressive fallback strategy:
1. Atlas text search (simple and fast)
2. Atlas autocomplete search (prefix matching with fuzzy)
3. Atlas compound search (comprehensive matching)
4. Regex fallback (when Atlas Search is unavailable)
"""
query_strip = query.strip()
if not query_strip:
return []
# First check if we have any nodes at all
try:
node_count = await self.collection.count_documents({})
if node_count == 0:
logger.debug(
f"[{self.workspace}] No nodes found in collection {self._collection_name}"
)
return []
except PyMongoError as e:
logger.error(f"[{self.workspace}] Error counting nodes: {e}")
return []
# Progressive search strategy
search_methods = [
("text", self._try_atlas_text_search),
("autocomplete", self._try_atlas_autocomplete_search),
("compound", self._try_atlas_compound_search),
]
# Try Atlas Search methods in order
for method_name, search_method in search_methods:
try:
labels = await search_method(query_strip, limit)
if labels:
logger.debug(
f"[{self.workspace}] Search successful using {method_name} method: {len(labels)} results"
)
return labels
else:
logger.debug(
f"[{self.workspace}] {method_name} search returned no results, trying next method"
)
except Exception as e:
logger.debug(
f"[{self.workspace}] {method_name} search failed: {e}, trying next method"
)
continue
# If all Atlas Search methods fail, use regex fallback
logger.info(
f"[{self.workspace}] All Atlas Search methods failed, using regex fallback search for: '{query_strip}'"
)
return await self._fallback_regex_search(query_strip, limit)
async def _check_if_index_needs_rebuild(
self, indexes: list, index_name: str
) -> bool:
"""Check if the existing index needs to be rebuilt due to configuration issues."""
for index in indexes:
if index["name"] == index_name:
# Check if the index has the old problematic configuration
definition = index.get("latestDefinition", {})
mappings = definition.get("mappings", {})
fields = mappings.get("fields", {})
id_field = fields.get("_id", {})
# If it's the old single-type autocomplete configuration, rebuild
if (
isinstance(id_field, dict)
and id_field.get("type") == "autocomplete"
):
logger.info(
f"[{self.workspace}] Found old index configuration for '{index_name}', will rebuild"
)
return True
# If it's not a list (multi-type configuration), rebuild
if not isinstance(id_field, list):
logger.info(
f"[{self.workspace}] Index '{index_name}' needs upgrade to multi-type configuration"
)
return True
logger.info(
f"[{self.workspace}] Index '{index_name}' has correct configuration"
)
return False
return True # Index doesn't exist, needs creation
async def _safely_drop_old_index(self, index_name: str):
"""Safely drop the old search index."""
try:
await self.collection.drop_search_index(index_name)
logger.info(
f"[{self.workspace}] Successfully dropped old search index '{index_name}'"
)
except PyMongoError as e:
logger.warning(
f"[{self.workspace}] Could not drop old index '{index_name}': {e}"
)
async def _create_improved_search_index(self, index_name: str):
"""Create an improved search index with multiple field types."""
search_index_model = SearchIndexModel(
definition={
"mappings": {
"dynamic": False,
"fields": {
"_id": [
{
"type": "string",
},
{
"type": "token",
},
{
"type": "autocomplete",
"maxGrams": 15,
"minGrams": 2,
},
]
},
},
"analyzer": "lucene.standard", # Index-level analyzer for text processing
},
name=index_name,
type="search",
)
await self.collection.create_search_index(search_index_model)
logger.info(
f"[{self.workspace}] Created improved Atlas Search index '{index_name}' for collection {self._collection_name}. "
)
logger.info(
f"[{self.workspace}] Index will be built asynchronously, using regex fallback until ready."
)
async def create_search_index_if_not_exists(self):
"""Creates an improved Atlas Search index for entity search, rebuilding if necessary."""
index_name = "entity_id_search_idx"
try:
# Check if we're using MongoDB Atlas (has search index capabilities)
indexes_cursor = await self.collection.list_search_indexes()
indexes = await indexes_cursor.to_list(length=None)
# Check if we need to rebuild the index
needs_rebuild = await self._check_if_index_needs_rebuild(
indexes, index_name
)
if needs_rebuild:
# Check if index exists and drop it
index_exists = any(idx["name"] == index_name for idx in indexes)
if index_exists:
await self._safely_drop_old_index(index_name)
# Create the improved search index (async, no waiting)
await self._create_improved_search_index(index_name)
else:
logger.info(
f"[{self.workspace}] Atlas Search index '{index_name}' already exists with correct configuration"
)
except PyMongoError as e:
# This is expected if not using MongoDB Atlas or if search indexes are not supported
logger.info(
f"[{self.workspace}] Could not create Atlas Search index for {self._collection_name}: {e}. "
"This is normal if not using MongoDB Atlas - search will use regex fallback."
)
except Exception as e:
logger.warning(
f"[{self.workspace}] Unexpected error creating Atlas Search index for {self._collection_name}: {e}"
)
async def drop(self) -> dict[str, str]:
"""Drop the storage by removing all documents in the collection.
Returns:
dict[str, str]: Status of the operation with keys 'status' and 'message'
"""
try:
result = await self.collection.delete_many({})
deleted_count = result.deleted_count
logger.info(
f"[{self.workspace}] Dropped {deleted_count} documents from graph {self._collection_name}"
)
result = await self.edge_collection.delete_many({})
edge_count = result.deleted_count
logger.info(
f"[{self.workspace}] Dropped {edge_count} edges from graph {self._edge_collection_name}"
)
return {
"status": "success",
"message": f"{deleted_count} documents and {edge_count} edges dropped",
}
except PyMongoError as e:
logger.error(
f"[{self.workspace}] Error dropping graph {self._collection_name}: {e}"
)
return {"status": "error", "message": str(e)}
@dataclass
class _PendingVectorDoc:
"""Buffered vector upsert waiting for embedding and/or bulk flush."""
source: dict[str, Any]
content: str
vector: list[float] | None = None
@final
@dataclass
class MongoVectorDBStorage(BaseVectorStorage):
db: AsyncDatabase | None = field(default=None)
_data: AsyncCollection | None = field(default=None)
_index_name: str = field(default="", init=False)
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__()
def __post_init__(self):
validate_workspace(self.workspace)
self._validate_embedding_func()
# Check for MONGODB_WORKSPACE environment variable first (higher priority)
# This allows administrators to force a specific workspace for all MongoDB storage instances
mongodb_workspace = os.environ.get("MONGODB_WORKSPACE")
if mongodb_workspace and mongodb_workspace.strip():
# Use environment variable value, overriding the passed workspace parameter
effective_workspace = mongodb_workspace.strip()
logger.info(
f"Using MONGODB_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}'"
)
# Build final_namespace with workspace prefix for data isolation
# Keep original namespace unchanged for type detection logic
if effective_workspace:
self.final_namespace = f"{effective_workspace}_{self.namespace}"
self.workspace = effective_workspace
logger.debug(
f"Final namespace with workspace prefix: '{self.final_namespace}'"
)
else:
# When workspace is empty, final_namespace equals original namespace
self.final_namespace = self.namespace
self.workspace = ""
logger.debug(f"Final namespace (no workspace): '{self.final_namespace}'")
# Set index name based on workspace for backward compatibility
if effective_workspace:
# Use collection-specific index name for workspaced collections to avoid conflicts
self._index_name = f"vector_knn_index_{self.final_namespace}"
else:
# Keep original index name for backward compatibility with existing deployments
self._index_name = "vector_knn_index"
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
self._collection_name = self.final_namespace
self._max_batch_size = self.global_config["embedding_batch_num"]
# Flush-time batching limits (see module-level DEFAULT_MONGO_* constants).
# A non-positive value disables that splitting dimension. The upsert and
# delete caps are shared across KV/graph/VDB via the _resolve_* helpers so
# every path stays under the same bulk message / 16MB query limit.
(
self._max_upsert_payload_bytes,
self._max_upsert_records_per_batch,
) = _resolve_upsert_batch_limits()
self._max_delete_records_per_batch = _resolve_delete_batch_limit()
# Deferred-embedding buffers and the per-namespace flush lock.
# Constructed in initialize() once shared-storage primitives are
# available; keyed on final_namespace so two instances pointing at
# the same MongoDB collection (e.g. with the MONGODB_WORKSPACE env
# override) share a single writer lock.
self._pending_vector_docs: dict[str, _PendingVectorDoc] = {}
self._pending_vector_deletes: set[str] = set()
self._flush_lock = None
async def initialize(self):
async with get_data_init_lock():
if self.db is None:
self.db = await ClientManager.get_client()
self._data = await get_or_create_collection(self.db, self._collection_name)
# Ensure vector index exists
await self.create_vector_index_if_not_exists()
logger.debug(
f"[{self.workspace}] Use MongoDB as VDB {self._collection_name}"
)
if self._flush_lock is None:
self._flush_lock = get_namespace_lock(
namespace=self.final_namespace, workspace=""
)
async def finalize(self):
"""Flush pending vector ops, release the Mongo client, surface unflushed data."""
flush_error: Exception | None = None
try:
await self._flush_pending_vector_ops()
except Exception as e:
flush_error = e
if self.db is not None:
await ClientManager.release_client(self.db)
self.db = None
self._data = None
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}] MongoVectorDBStorage.finalize() flush raised; "
f"{pending_docs} pending upserts and {pending_deletes} pending "
f"deletes were left buffered (client released, data lost)"
) from flush_error
if pending_docs or pending_deletes:
raise RuntimeError(
f"[{self.workspace}] MongoVectorDBStorage.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 _wait_for_search_index_absent(
self, index_name: str, *, timeout: float = 120.0, interval: float = 2.0
) -> None:
"""Poll until a dropped search index disappears.
``create_search_index`` rejects a name that still exists while the
prior drop is in the DELETING state, so a recreate must wait for the
old index to clear first. Best-effort: on timeout it logs and returns
so the subsequent create surfaces any genuine conflict itself rather
than blocking initialize() indefinitely.
"""
deadline = time.monotonic() + timeout
while True:
cursor = await self._data.list_search_indexes()
names = {idx["name"] for idx in await cursor.to_list(length=None)}
if index_name not in names:
return
if time.monotonic() >= deadline:
logger.warning(
f"[{self.workspace}] dropped search index {index_name} still "
f"present after {timeout:.0f}s; proceeding to recreate"
)
return
await asyncio.sleep(interval)
async def create_vector_index_if_not_exists(self):
"""Create the Atlas Vector Search index, repairing a FAILED one.
Atlas/mongot leaves a vector index in the terminal ``FAILED`` state
after a build error and never retries it on its own; when that index
is also non-queryable every subsequent ``$vectorSearch`` raises
``cannot query vector index ... while in state FAILED``. Matching the
index only by name would treat that dead index as healthy and wedge
all queries permanently, so a non-queryable, same-dimension FAILED
index is dropped and rebuilt here.
Two guards run *before* the rebuild: (1) a FAILED index that is still
``queryable`` (a background rebuild/update failed but the previously
built index keeps serving) is left in place to avoid taking a
still-serving index offline; (2) a FAILED index built under a
different embedding model raises rather than being auto-rebuilt
against incompatible stored vectors. Transitional states
(``PENDING``/``BUILDING``) are left alone -- they become queryable
without intervention.
"""
try:
indexes_cursor = await self._data.list_search_indexes()
indexes = await indexes_cursor.to_list(length=None)
for index in indexes:
if index["name"] != self._index_name:
continue
# Read the stored vector dimension first so the mismatch
# guard below runs even for a FAILED index. A FAILED index
# built under a *different* embedding model must NOT be
# silently auto-rebuilt: recreating with the new dimension
# against incompatible stored vectors would just FAIL again
# and hide the required data-directory reset from the
# operator. Only a same-dimension FAILED index is self-healed.
existing_dim = None
definition = index.get("latestDefinition", {})
fields = definition.get("fields", [])
for field in fields:
if field.get("type") == "vector" and field.get("path") == "vector":
existing_dim = field.get("numDimensions")
break
expected_dim = self.embedding_func.embedding_dim
if existing_dim is not None and existing_dim != expected_dim:
error_msg = (
f"Vector dimension mismatch! Index '{self._index_name}' has "
f"dimension {existing_dim}, but current embedding model expects "
f"dimension {expected_dim}. Please drop the existing index or "
f"use an embedding model with matching dimensions."
)
logger.error(f"[{self.workspace}] {error_msg}")
raise ValueError(error_msg)
# Self-heal a FAILED index, but ONLY when it is actually
# non-queryable. Atlas can report status="FAILED" while
# queryable=true -- e.g. a background rebuild/update failed
# yet the previously-built index keeps serving queries (see
# the listSearchIndexes status docs). Dropping such an index
# here would take a still-serving index offline and cause
# avoidable query downtime while we wait for deletion and
# rebuild. Reached only once the dimension guard above
# confirmed the stored dimension matches.
if index.get("status") == "FAILED":
if index.get("queryable", True):
logger.warning(
f"[{self.workspace}] vector index {self._index_name} reports "
f"FAILED status but is still queryable; leaving the active "
f"index in place. A background rebuild/update likely failed -- "
f"inspect $listSearchIndexes statusDetail and drop/rebuild "
f"manually if queries degrade."
)
return
# Non-queryable FAILED build is terminal: drop and fall
# through to recreate (the same self-heal `drop()` relies
# on). Wait for the drop to clear first -- create_search_index
# rejects a name that still exists while the old index is
# DELETING.
logger.warning(
f"[{self.workspace}] vector index {self._index_name} is FAILED "
f"and non-queryable; dropping and recreating it"
)
await self._data.drop_search_index(self._index_name)
await self._wait_for_search_index_absent(self._index_name)
break
logger.info(
f"[{self.workspace}] vector index {self._index_name} already exists with matching dimensions ({expected_dim})"
)
return
search_index_model = SearchIndexModel(
definition={
"fields": [
{
"type": "vector",
"numDimensions": self.embedding_func.embedding_dim, # Ensure correct dimensions
"path": "vector",
"similarity": "cosine", # Options: euclidean, cosine, dotProduct
}
]
},
name=self._index_name,
type="vectorSearch",
)
await self._data.create_search_index(search_index_model)
logger.info(
f"[{self.workspace}] Vector index {self._index_name} created successfully."
)
except PyMongoError as e:
error_msg = f"[{self.workspace}] Error creating vector index {self._index_name}: {e}"
logger.error(error_msg)
raise SystemExit(
f"Failed to create MongoDB vector index. Program cannot continue. {error_msg}"
)
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. Reads observe pending docs via the
same lock for read-your-writes.
"""
if not data:
return
current_time = int(time.time())
pending_docs: list[tuple[str, _PendingVectorDoc]] = []
for i, (k, v) in enumerate(data.items(), start=1):
source = {
"_id": k,
"created_at": 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)
# 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]]:
"""Queries the vector database using Atlas Vector Search.
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
OpenSearch / FAISS / Nano.
"""
if query_embedding is not None:
# Convert numpy array to list if needed for MongoDB compatibility
if hasattr(query_embedding, "tolist"):
query_vector = query_embedding.tolist()
else:
query_vector = list(query_embedding)
else:
# Generate the embedding
embedding = await self.embedding_func(
[query], context="query", _priority=DEFAULT_QUERY_PRIORITY
) # higher priority for query
# Convert numpy array to a list to ensure compatibility with MongoDB
query_vector = embedding[0].tolist()
# Define the aggregation pipeline with the converted query vector
pipeline = [
{
"$vectorSearch": {
"index": self._index_name, # Use stored index name for consistency
"path": "vector",
"queryVector": query_vector,
"numCandidates": 100, # Adjust for performance
"limit": top_k,
}
},
{"$addFields": {"score": {"$meta": "vectorSearchScore"}}},
{"$match": {"score": {"$gte": self.cosine_better_than_threshold}}},
{"$project": {"vector": 0}},
]
# Execute the aggregation pipeline
cursor = await self._data.aggregate(pipeline, allowDiskUse=True)
results = await cursor.to_list(length=None)
# Format and return the results with created_at field
return [
{
**doc,
"id": doc["_id"],
"distance": doc.get("score", None),
"created_at": doc.get("created_at"), # Include created_at field
}
for doc in results
]
async def index_done_callback(self) -> None:
"""Flush buffered vector ops; Mongo 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 in batched bulk writes.
Embedding runs *inside* this lock (not in `upsert` or lock-free):
it makes deferred embedding and the bulk write atomic against
concurrent upserts and destructive mutations. Any failure (embed
or server write) raises and leaves both buffers intact; the next
`index_done_callback` retries automatically.
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._data 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
):
pdoc.vector = np.array(embedding, dtype=np.float32).tolist()
await _cooperative_yield(i)
# Assemble final upsert payload. After the embed loop above every
# pending doc has a non-None vector (count-mismatch was checked),
# so we can iterate without re-guarding. Each full_doc carries its
# own "_id" (from source), matching the UpdateOne filter key.
ids_to_commit: list[str] = list(pending_docs.keys())
list_data: list[dict[str, Any]] = [
{**pending_docs[doc_id].source, "vector": pending_docs[doc_id].vector}
for doc_id in ids_to_commit
]
try:
if list_data:
# Split the upsert into batches that stay under the server-side
# bulk-command message limit and bound peak memory. Fail-fast:
# any batch failure raises immediately and the full buffer is
# retained for the next flush (upsert/delete are idempotent).
# Logging is kept aligned with MilvusVectorDBStorage; the
# batching maths is shared via _chunk_by_budget.
upsert_batches = _chunk_by_budget(
list_data,
_estimate_doc_bytes,
self._max_upsert_payload_bytes,
self._max_upsert_records_per_batch,
)
if len(upsert_batches) > 1:
logger.info(
f"[{self.workspace}] {self.namespace} flush: upsert split into "
f"{len(upsert_batches)} batches for {len(list_data)} records "
f"(max_payload={self._max_upsert_payload_bytes} batch={self._max_upsert_records_per_batch})"
)
for batch_index, (records_batch, estimated_bytes) in enumerate(
upsert_batches, 1
):
if (
len(records_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 record "
f"id={records_batch[0].get('_id')} estimated {estimated_bytes} bytes "
f"exceeds {self._max_upsert_payload_bytes}"
)
logger.debug(
f"[{self.workspace}] MongoDB upsert batch {batch_index}/{len(upsert_batches)}: "
f"records={len(records_batch)}, estimated_payload_bytes={estimated_bytes}"
)
await self._data.bulk_write(
[
UpdateOne(
{"_id": doc["_id"]}, {"$set": doc}, upsert=True
)
for doc in records_batch
],
ordered=False,
)
if pending_deletes:
# Chunk deletes by record count; _ids are short strings so a
# count cap is enough to stay under the bulk message limit.
# delete_many($in) is the 1:1 equivalent of a batched delete.
delete_ids = list(pending_deletes)
delete_chunk = (
self._max_delete_records_per_batch
if self._max_delete_records_per_batch > 0
else len(delete_ids)
)
for i in range(0, len(delete_ids), delete_chunk):
await self._data.delete_many(
{"_id": {"$in": delete_ids[i : i + delete_chunk]}}
)
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
# On success, clear the buffers in-place so external references
# (e.g. drop()) see the cleared state.
for doc_id in ids_to_commit:
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="ent-")
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 find
+ delete cannot interleave with an in-flight bulk write. 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 ``delete_many``
succeeds. On failure the pending buffer stays intact and
the exception propagates so the caller (``adelete_by_entity``
in ``utils_graph.py``) can short-circuit before
``_persist_graph_updates`` flushes a half-cleaned buffer.
"""
def _prune_pending() -> None:
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)
async with self._flush_lock:
if self._data is None:
# No server state to mutate; buffer prune is the only
# delete intent we can record.
_prune_pending()
return
# _id is the only field we need from the find; project to keep
# the cursor light.
relations_cursor = self._data.find(
{"$or": [{"src_id": entity_name}, {"tgt_id": entity_name}]},
{"_id": 1},
)
relations = await relations_cursor.to_list(length=None)
if not relations:
# No server rows to delete — still safe to prune any
# pending upserts so they can't re-create the relation.
_prune_pending()
logger.debug(
f"[{self.workspace}] No relations found for entity {entity_name}"
)
return
relation_ids = [relation["_id"] for relation in relations]
await self._data.delete_many({"_id": {"$in": relation_ids}})
# Server-side delete succeeded — safe to prune the pending
# buffer so subsequent flushes don't re-upsert the deleted
# relations.
_prune_pending()
logger.debug(
f"[{self.workspace}] Deleted {len(relation_ids)} relations for {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.
Pending buffer hits never include the `vector` field; server-side
fallback projects it out for parity.
"""
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:
doc = dict(pending.source)
# Surface both _id (Mongo native) and id (API expectation).
doc.setdefault("_id", id)
doc["id"] = id
return doc
try:
result = await self._data.find_one({"_id": id}, {"vector": 0})
if result:
result_dict = dict(result)
if "_id" in result_dict and "id" not in result_dict:
result_dict["id"] = result_dict["_id"]
return result_dict
return None
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:
doc = dict(pending.source)
doc.setdefault("_id", doc_id)
doc["id"] = doc_id
buffered[doc_id] = doc
continue
remaining.append(doc_id)
formatted_map: dict[str, dict[str, Any]] = {}
if remaining:
try:
cursor = self._data.find({"_id": {"$in": remaining}}, {"vector": 0})
results = await cursor.to_list(length=None)
for result in results:
result_dict = dict(result)
if "_id" in result_dict and "id" not in result_dict:
result_dict["id"] = result_dict["_id"]
key = str(result_dict.get("id", result_dict.get("_id")))
formatted_map[key] = result_dict
except Exception as e:
logger.error(
f"[{self.workspace}] Error retrieving vector data for IDs {remaining}: {e}"
)
return []
return [
buffered[doc_id] if doc_id in buffered else formatted_map.get(str(doc_id))
for doc_id in ids
]
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
``find`` fallback runs *outside* ``_flush_lock``. A concurrent
``delete()`` that lands between lock release and the cursor
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:
cursor = self._data.find(
{"_id": {"$in": remaining}}, {"_id": 1, "vector": 1}
)
results = await cursor.to_list(length=None)
for row in results:
if row and "vector" in row and "_id" in row:
result[row["_id"]] = row["vector"]
return result
except PyMongoError as e:
logger.error(f"[{self.workspace}] Error getting vectors: {e}")
return result
async def drop(self) -> dict[str, str]:
"""Drop all documents and recreate the vector index. Destructive.
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.
Caveat — only this instance's buffers are cleared. Other
``MongoVectorDBStorage`` instances aliased onto the same
``final_namespace`` (multi-worker processes, or distinct
workspaces collapsed by ``MONGODB_WORKSPACE``) keep their own
buffers; a sibling whose prior flush failed and left buffers
intact will, on its next flush, bulk-write those stale rows into
the freshly recreated collection. 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 any buffered writes before the collection is wiped;
# a concurrent flush would otherwise resurrect them.
self._pending_vector_docs.clear()
self._pending_vector_deletes.clear()
# Delete all documents
result = await self._data.delete_many({})
deleted_count = result.deleted_count
# Recreate vector index
await self.create_vector_index_if_not_exists()
logger.info(
f"[{self.workspace}] Dropped {deleted_count} documents from vector storage {self._collection_name} and recreated vector index"
)
return {
"status": "success",
"message": f"{deleted_count} documents dropped and vector index recreated",
}
except PyMongoError as e:
logger.error(
f"[{self.workspace}] Error dropping vector storage {self._collection_name}: {e}"
)
return {"status": "error", "message": str(e)}
async def get_or_create_collection(db: AsyncDatabase, collection_name: str):
collection_names = await db.list_collection_names()
if collection_name not in collection_names:
collection = await db.create_collection(collection_name)
logger.info(f"Created collection: {collection_name}")
return collection
else:
logger.debug(f"Collection '{collection_name}' already exists.")
return db.get_collection(collection_name)