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

2969 lines
126 KiB
Python

import asyncio
import json
import os
import time
from typing import Any, final, Optional, Dict
from dataclasses import dataclass, fields
import numpy as np
from lightrag.utils import (
logger,
compute_mdhash_id,
_cooperative_yield,
validate_workspace,
)
from ..base import BaseVectorStorage
from ..constants import (
DEFAULT_MAX_FILE_PATH_LENGTH,
DEFAULT_QUERY_PRIORITY,
GRAPH_FIELD_SEP,
)
from ..kg.shared_storage import get_data_init_lock, get_namespace_lock
import pipmaster as pm
if not pm.is_installed("pymilvus"):
pm.install("pymilvus>=2.6.2")
import configparser
import grpc # type: ignore
from pymilvus import ( # type: ignore
MilvusClient,
MilvusException,
DataType,
CollectionSchema,
FieldSchema,
)
from packaging import version
config = configparser.ConfigParser()
config.read("config.ini", "utf-8")
@dataclass
class _PendingVectorDoc:
"""Buffered vector upsert waiting for embedding and/or bulk flush."""
source: dict[str, Any]
content: str
vector: list[float] | None = None
# Flush-time batching limits. Milvus' server-side proxy rejects any single
# gRPC message larger than ~64MB (grpc.serverMaxRecvSize); the client library
# cannot raise that ceiling, so large flushes must be split client-side.
# 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 pks, so the
# upsert batch count is kept much smaller than the delete one.
DEFAULT_MILVUS_UPSERT_MAX_PAYLOAD_BYTES = (
32 * 1024 * 1024
) # 32MB, well below the 64MB gRPC ceiling
DEFAULT_MILVUS_UPSERT_MAX_RECORDS_PER_BATCH = 128
DEFAULT_MILVUS_DELETE_MAX_RECORDS_PER_BATCH = 1000
# Schema-migration resilience. A transient Milvus outage during the long
# iterator-based migration must not kill worker startup: when pymilvus'
# internal reconnect fails it closes the gRPC channel for good, so every later
# call on the same client raises "Cannot invoke RPC on closed channel!". On a
# connection-class failure the whole migration attempt is therefore retried
# from scratch with a rebuilt MilvusClient (the source collection is untouched
# until the final rename and each attempt drops the leftover _temp collection
# first, so a full re-run is always safe). The max backoff is kept above
# pymilvus' connection-pool idle health-check threshold (IDLE_THRESHOLD_SECONDS
# = 30s in pymilvus 3.x) so a rebuilt client is guaranteed to get a
# health-checked/recovered channel rather than the same dead pooled handler.
DEFAULT_MILVUS_MIGRATION_MAX_RETRIES = 5
DEFAULT_MILVUS_MIGRATION_RETRY_BACKOFF_SECONDS = 5.0
DEFAULT_MILVUS_MIGRATION_RETRY_MAX_BACKOFF_SECONDS = 60.0
MILVUS_MIGRATION_RETRY_BACKOFF_MULTIPLIER = 3.0
DEFAULT_MILVUS_MIGRATION_ITERATOR_BATCH_SIZE = 2000
# Schema-migration memory back-pressure. The bulk copy inserts the whole source
# collection into the temp collection with no client-side throttle, so the
# Milvus data node accumulates growing insert-buffer segments until its own
# auto-flush catches up; under a large migration this can exhaust server memory
# (the temp collection is not loaded, so query-node memory is already bounded —
# this is the data-node write buffer). Flushing every N migrated rows seals
# those segments to object storage and blocks until the flush returns, giving
# the server natural back-pressure. 0 disables periodic flush (rely on Milvus
# auto-flush). The interval is kept coarse so it does not spawn many tiny
# segments (which would burden later compaction). An optional per-batch sleep
# lets a small server breathe between batches; 0 disables it.
DEFAULT_MILVUS_MIGRATION_FLUSH_INTERVAL_ROWS = 50000
DEFAULT_MILVUS_MIGRATION_BATCH_SLEEP_SECONDS = 0.0
# Substrings that mark an exception as a transient connection failure (worth a
# retry with a rebuilt client) rather than a schema/parameter error.
MILVUS_RETRYABLE_CONNECTION_ERROR_MARKERS = (
"unavailable", # grpc UNAVAILABLE / "server unavailable"
"ping timeout",
"deadline exceeded",
"connection refused",
"connection reset",
"broken pipe",
"closed channel", # ValueError: Cannot invoke RPC on closed channel!
"fail connecting to server", # pymilvus _wait_for_channel_ready
"failed to connect",
)
MILVUS_MAX_VARCHAR_BYTES = 65535
# The Milvus primary key. Truncating it would let two distinct ids collapse to
# the same key (silent overwrite) and make the row unreachable by its real id
# via get_by_id/delete, so it must never be truncated under any circumstance.
MILVUS_PRIMARY_KEY_FIELDS = frozenset({"id"})
# Non-primary identity fields. They are not the Milvus primary key, so the row
# stays uniquely keyed by `id` even if these collide after truncation (no
# storage-level overwrite). On the live upsert path we still reject oversize
# values so callers fix their input; during migration of pre-existing data we
# truncate-and-warn instead, so a single pathological legacy value cannot abort
# the whole collection migration.
MILVUS_IDENTITY_VARCHAR_FIELDS = frozenset(
{"id", "entity_name", "full_doc_id", "src_id", "tgt_id"}
)
# Fields whose value is a GRAPH_FIELD_SEP-joined list of ids (chunk ids / file
# paths). When such a value overflows we truncate on the last separator that
# fits rather than mid-id, so we drop whole ids instead of leaving a dangling
# partial id that resolves to nothing.
MILVUS_SEPARATOR_JOINED_FIELDS = frozenset({"source_id", "file_path"})
# Supported index types
SUPPORTED_INDEX_TYPES = {
"AUTOINDEX",
"HNSW",
"HNSW_SQ",
"HNSW_PQ",
"HNSW_PRQ",
"IVF_FLAT",
"IVF_SQ8",
"IVF_PQ",
"DISKANN",
"SCANN",
}
# Supported metric types
SUPPORTED_METRIC_TYPES = {"COSINE", "L2", "IP"}
# HNSW_SQ quantization types
SUPPORTED_SQ_TYPES = {"SQ4U", "SQ6", "SQ8", "BF16", "FP16"}
SUPPORTED_REFINE_TYPES = {"SQ6", "SQ8", "BF16", "FP16", "FP32"}
# Index type version requirements
# Important: HNSW_SQ was first introduced in Milvus 2.6.8 (not 2.5)
INDEX_VERSION_REQUIREMENTS = {
"HNSW_SQ": "2.6.8", # HNSW_SQ requires Milvus 2.6.8+ (supports sq_types such as SQ4U, SQ6, SQ8, BF16, FP16)
}
def _get_env_bool(key: str, default: bool = False) -> bool:
"""Parse environment variable as boolean"""
val = os.environ.get(key, "").lower()
if val in ("true", "1", "yes", "on"):
return True
elif val in ("false", "0", "no", "off"):
return False
return default
def _get_env_int(key: str, default: int) -> int:
"""Parse environment variable as integer"""
val = os.environ.get(key, "")
if val:
try:
return int(val)
except ValueError:
logger.warning(
f"Invalid integer value for {key}: {val}, using default {default}"
)
return default
@dataclass
class MilvusIndexConfig:
"""
Milvus vector index configuration class
Supports configuration via environment variables or initialization parameters.
Initialization parameters take precedence over environment variables.
"""
# Base configuration
index_type: Optional[str] = None
metric_type: Optional[str] = None
# HNSW series parameters
hnsw_m: Optional[int] = None
hnsw_ef_construction: Optional[int] = None
hnsw_ef: Optional[int] = None
# HNSW_SQ specific parameters
sq_type: Optional[str] = None
sq_refine: Optional[bool] = None
sq_refine_type: Optional[str] = None
sq_refine_k: Optional[int] = None
# IVF series parameters
ivf_nlist: Optional[int] = None
ivf_nprobe: Optional[int] = None
def __post_init__(self):
"""Load configuration from environment variables (init parameters take precedence)"""
# Index type
self.index_type = (
self.index_type or os.environ.get("MILVUS_INDEX_TYPE", "AUTOINDEX")
).upper()
# Metric type
self.metric_type = (
self.metric_type or os.environ.get("MILVUS_METRIC_TYPE", "COSINE")
).upper()
# HNSW parameters
# Defaults aligned with Milvus 2.4+ official documentation
if self.hnsw_m is None:
self.hnsw_m = _get_env_int("MILVUS_HNSW_M", 16)
if self.hnsw_ef_construction is None:
self.hnsw_ef_construction = _get_env_int("MILVUS_HNSW_EF_CONSTRUCTION", 360)
if self.hnsw_ef is None:
self.hnsw_ef = _get_env_int("MILVUS_HNSW_EF", 200)
# HNSW_SQ parameters
if self.sq_type is None:
self.sq_type = os.environ.get("MILVUS_HNSW_SQ_TYPE", "SQ8").upper()
if self.sq_refine is None:
self.sq_refine = _get_env_bool("MILVUS_HNSW_SQ_REFINE", False)
if self.sq_refine_type is None:
self.sq_refine_type = os.environ.get(
"MILVUS_HNSW_SQ_REFINE_TYPE", "FP32"
).upper()
if self.sq_refine_k is None:
self.sq_refine_k = _get_env_int("MILVUS_HNSW_SQ_REFINE_K", 10)
# IVF parameters
if self.ivf_nlist is None:
self.ivf_nlist = _get_env_int("MILVUS_IVF_NLIST", 1024)
if self.ivf_nprobe is None:
self.ivf_nprobe = _get_env_int("MILVUS_IVF_NPROBE", 16)
# Validate configuration
self._validate()
def _validate(self):
"""Validate configuration validity"""
if self.index_type not in SUPPORTED_INDEX_TYPES:
raise ValueError(
f"Unsupported index type: {self.index_type}. "
f"Supported: {SUPPORTED_INDEX_TYPES}"
)
if self.metric_type not in SUPPORTED_METRIC_TYPES:
raise ValueError(
f"Unsupported metric type: {self.metric_type}. "
f"Supported: {SUPPORTED_METRIC_TYPES}"
)
if self.index_type == "HNSW_SQ":
if self.sq_type not in SUPPORTED_SQ_TYPES:
raise ValueError(
f"Unsupported sq_type: {self.sq_type}. "
f"Supported: {SUPPORTED_SQ_TYPES}"
)
if self.sq_refine and self.sq_refine_type not in SUPPORTED_REFINE_TYPES:
raise ValueError(
f"Unsupported refine_type: {self.sq_refine_type}. "
f"Supported: {SUPPORTED_REFINE_TYPES}"
)
# Parameter range validation
if not (2 <= self.hnsw_m <= 2048):
raise ValueError(f"hnsw_m must be in [2, 2048], got {self.hnsw_m}")
if self.hnsw_ef_construction < 1:
raise ValueError(
f"hnsw_ef_construction must be >= 1, got {self.hnsw_ef_construction}"
)
if self.ivf_nlist < 1 or self.ivf_nlist > 65536:
raise ValueError(f"ivf_nlist must be in [1, 65536], got {self.ivf_nlist}")
def validate_milvus_version(self, server_version: str) -> None:
"""
Validate Milvus server version supports the configured index type
Args:
server_version: Milvus server version string (e.g., "2.6.9")
Raises:
ValueError: Version does not meet index type requirements
"""
current_ver = version.parse(
server_version.split("-")[0]
) # Handle "2.6.9-dev" format
# Check HNSW_SQ index type version requirements (requires 2.6.8+)
if self.index_type == "HNSW_SQ":
required = INDEX_VERSION_REQUIREMENTS["HNSW_SQ"]
if current_ver < version.parse(required):
raise ValueError(
f"HNSW_SQ requires Milvus {required}+, "
f"current version: {server_version}"
)
logger.info(
f"Milvus version {server_version} validated for index type "
f"{self.index_type}"
+ (f" with sq_type {self.sq_type}" if self.index_type == "HNSW_SQ" else "")
)
def build_index_params(self, index_params, field_name: str = "vector"):
"""
Build pymilvus index parameters
Args:
index_params: IndexParams instance (from compatibility helper or client.prepare_index_params())
field_name: Vector field name
Returns:
IndexParams object, or a dict fallback when direct API creation is needed.
"""
if index_params is None:
if self.index_type == "AUTOINDEX":
logger.info(
"Using AUTOINDEX with direct API fallback because IndexParams is unavailable"
)
return {
"field_name": field_name,
"index_type": self.index_type,
"metric_type": self.metric_type,
"params": {},
}
raise RuntimeError(
f"IndexParams not available but required for index type "
f"'{self.index_type}'. Ensure pymilvus is installed correctly."
)
params: Dict[str, Any] = {}
# HNSW series indexes
if self.index_type in ("HNSW", "HNSW_SQ", "HNSW_PQ", "HNSW_PRQ"):
params["M"] = self.hnsw_m
params["efConstruction"] = self.hnsw_ef_construction
# HNSW_SQ specific parameters
if self.index_type == "HNSW_SQ":
params["sq_type"] = self.sq_type
if self.sq_refine:
params["refine"] = True
params["refine_type"] = self.sq_refine_type
# IVF series indexes
elif self.index_type in ("IVF_FLAT", "IVF_SQ8", "IVF_PQ"):
params["nlist"] = self.ivf_nlist
# DISKANN / SCANN have no additional params
index_params.add_index(
field_name=field_name,
index_type=self.index_type,
metric_type=self.metric_type,
params=params,
)
logger.info(
f"Milvus index configured: type={self.index_type}, "
f"metric={self.metric_type}, params={params}"
)
return index_params
def build_search_params(self) -> Dict[str, Any]:
"""
Build search parameters
Returns:
Search parameters dictionary
"""
search_params: Dict[str, Any] = {}
if self.index_type in ("HNSW", "HNSW_SQ", "HNSW_PQ", "HNSW_PRQ"):
search_params["ef"] = self.hnsw_ef
if self.index_type == "HNSW_SQ" and self.sq_refine:
search_params["refine_k"] = self.sq_refine_k
elif self.index_type in ("IVF_FLAT", "IVF_SQ8", "IVF_PQ"):
search_params["nprobe"] = self.ivf_nprobe
return {"params": search_params} if search_params else {}
@classmethod
def get_config_field_names(cls) -> set:
"""Get all configuration field names from the dataclass.
This method provides a single source of truth for configuration parameter names,
eliminating the need to maintain duplicate hardcoded lists elsewhere.
Returns:
Set of field names that can be used to extract configuration from kwargs
"""
return {f.name for f in fields(cls)}
def to_dict(self) -> Dict[str, Any]:
"""Export configuration as dictionary (for logging/debugging)"""
return {
"index_type": self.index_type,
"metric_type": self.metric_type,
"hnsw_m": self.hnsw_m,
"hnsw_ef_construction": self.hnsw_ef_construction,
"hnsw_ef": self.hnsw_ef,
"sq_type": self.sq_type if self.index_type == "HNSW_SQ" else None,
"sq_refine": self.sq_refine if self.index_type == "HNSW_SQ" else None,
"sq_refine_type": (
self.sq_refine_type
if self.index_type == "HNSW_SQ" and self.sq_refine
else None
),
"sq_refine_k": (
self.sq_refine_k
if self.index_type == "HNSW_SQ" and self.sq_refine
else None
),
"ivf_nlist": (
self.ivf_nlist if self.index_type.startswith("IVF") else None
),
"ivf_nprobe": (
self.ivf_nprobe if self.index_type.startswith("IVF") else None
),
}
@final
@dataclass
class MilvusVectorDBStorage(BaseVectorStorage):
def _get_milvus_connection_kwargs(self, include_db_name: bool = True) -> dict:
"""Build Milvus connection kwargs from env/config."""
connection_kwargs = {
"uri": os.environ.get(
"MILVUS_URI",
config.get(
"milvus",
"uri",
fallback=os.path.join(
self.global_config["working_dir"], "milvus_lite.db"
),
),
),
"user": os.environ.get(
"MILVUS_USER", config.get("milvus", "user", fallback=None)
),
"password": os.environ.get(
"MILVUS_PASSWORD",
config.get("milvus", "password", fallback=None),
),
"token": os.environ.get(
"MILVUS_TOKEN", config.get("milvus", "token", fallback=None)
),
}
db_name = os.environ.get(
"MILVUS_DB_NAME",
config.get("milvus", "db_name", fallback=None),
)
if include_db_name and db_name:
connection_kwargs["db_name"] = db_name
return connection_kwargs
def _get_milvus_db_name(self) -> Optional[str]:
"""Return the configured Milvus database name, if any."""
db_name = self._get_milvus_connection_kwargs(include_db_name=True).get(
"db_name"
)
if db_name is None:
return None
normalized_name = str(db_name).strip()
return normalized_name or None
def _create_milvus_client(self) -> MilvusClient:
"""Create a Milvus client and ensure the configured database exists."""
client = MilvusClient(
**self._get_milvus_connection_kwargs(include_db_name=False)
)
db_name = self._get_milvus_db_name()
if not db_name:
return client
existing_databases = set(client.list_databases())
if db_name not in existing_databases:
logger.warning(
f"[{self.workspace}] Milvus database '{db_name}' not found, creating it"
)
client.create_database(db_name)
use_database = getattr(client, "use_database", None) or getattr(
client, "using_database", None
)
if callable(use_database):
use_database(db_name)
logger.debug(
f"[{self.workspace}] Using Milvus database '{db_name}' for namespace '{self.namespace}'"
)
return client
return MilvusClient(**self._get_milvus_connection_kwargs(include_db_name=True))
def _rebuild_milvus_client(self) -> None:
"""Replace the (possibly dead) client with a freshly created one.
Once pymilvus' internal reconnect has failed, the gRPC channel is
closed permanently and every later RPC on the same client raises
"Cannot invoke RPC on closed channel!" — the client cannot heal
itself, so migration retries must rebuild it. Safe during migration:
it runs inside get_data_init_lock(), so this instance owns
self._client exclusively. close() is best-effort — on a dead channel
it is a no-op release. In pymilvus 3.x the pooled handler is
health-checked and recovered in place, which also heals other clients
sharing the same address.
"""
old_client, self._client = self._client, None
if old_client is not None:
try:
old_client.close()
except Exception as close_error:
logger.warning(
f"[{self.workspace}] Failed to close stale Milvus client: {close_error}"
)
self._client = self._create_milvus_client()
@staticmethod
def _is_retryable_connection_error(error: BaseException) -> bool:
"""Return True when the error chain indicates a transient connection failure.
Walks __cause__/__context__ because the migration wraps low-level
errors in RuntimeError and pymilvus wraps grpc errors in
MilvusException. Schema, dimension and parameter errors fall through
to False so they keep failing fast.
"""
seen: set[int] = set()
current: BaseException | None = error
while current is not None and id(current) not in seen:
seen.add(id(current))
if isinstance(current, grpc.RpcError):
code_getter = getattr(current, "code", None)
code = code_getter() if callable(code_getter) else None
if code in (
grpc.StatusCode.UNAVAILABLE,
grpc.StatusCode.DEADLINE_EXCEEDED,
):
return True
elif isinstance(current, MilvusException):
# Status.CONNECT_FAILED == 2 (client-side connect failure)
message = str(current).lower()
if current.code == 2 or any(
marker in message
for marker in MILVUS_RETRYABLE_CONNECTION_ERROR_MARKERS
):
return True
elif isinstance(current, ValueError):
# grpc raises ValueError("Cannot invoke RPC on closed channel!")
if "closed channel" in str(current).lower():
return True
current = current.__cause__ or current.__context__
return False
def _create_schema_for_namespace(self) -> CollectionSchema:
"""Create schema based on the current instance's namespace"""
# Get vector dimension from embedding_func
dimension = self.embedding_func.embedding_dim
varchar_limits = self._get_varchar_field_limits_for_namespace()
# Base fields (common to all collections)
base_fields = [
FieldSchema(
name="id", dtype=DataType.VARCHAR, max_length=64, is_primary=True
),
FieldSchema(name="vector", dtype=DataType.FLOAT_VECTOR, dim=dimension),
FieldSchema(name="created_at", dtype=DataType.INT64),
]
# Determine specific fields based on namespace
if self.namespace.endswith("entities"):
specific_fields = [
FieldSchema(
name="entity_name",
dtype=DataType.VARCHAR,
max_length=varchar_limits["entity_name"],
nullable=True,
),
FieldSchema(
name="content",
dtype=DataType.VARCHAR,
max_length=varchar_limits["content"],
nullable=True,
),
FieldSchema(
name="source_id",
dtype=DataType.VARCHAR,
max_length=varchar_limits["source_id"],
nullable=True,
),
FieldSchema(
name="file_path",
dtype=DataType.VARCHAR,
max_length=varchar_limits["file_path"],
nullable=True,
),
]
description = "LightRAG entities vector storage"
elif self.namespace.endswith("relationships"):
specific_fields = [
FieldSchema(
name="src_id",
dtype=DataType.VARCHAR,
max_length=varchar_limits["src_id"],
nullable=True,
),
FieldSchema(
name="tgt_id",
dtype=DataType.VARCHAR,
max_length=varchar_limits["tgt_id"],
nullable=True,
),
FieldSchema(
name="content",
dtype=DataType.VARCHAR,
max_length=varchar_limits["content"],
nullable=True,
),
FieldSchema(
name="source_id",
dtype=DataType.VARCHAR,
max_length=varchar_limits["source_id"],
nullable=True,
),
FieldSchema(
name="file_path",
dtype=DataType.VARCHAR,
max_length=varchar_limits["file_path"],
nullable=True,
),
]
description = "LightRAG relationships vector storage"
elif self.namespace.endswith("chunks"):
specific_fields = [
FieldSchema(
name="full_doc_id",
dtype=DataType.VARCHAR,
max_length=varchar_limits["full_doc_id"],
nullable=True,
),
FieldSchema(
name="content",
dtype=DataType.VARCHAR,
max_length=varchar_limits["content"],
nullable=True,
),
FieldSchema(
name="file_path",
dtype=DataType.VARCHAR,
max_length=varchar_limits["file_path"],
nullable=True,
),
]
description = "LightRAG chunks vector storage"
else:
# Default generic schema (backward compatibility)
specific_fields = [
FieldSchema(
name="file_path",
dtype=DataType.VARCHAR,
max_length=varchar_limits["file_path"],
nullable=True,
),
]
description = "LightRAG generic vector storage"
# Merge all fields
all_fields = base_fields + specific_fields
return CollectionSchema(
fields=all_fields,
description=description,
enable_dynamic_field=True, # Support dynamic fields
)
def _get_varchar_field_limits_for_namespace(self) -> dict[str, int]:
base_fields = {
"id": 64,
"content": MILVUS_MAX_VARCHAR_BYTES,
"file_path": DEFAULT_MAX_FILE_PATH_LENGTH,
}
if self.namespace.endswith("entities"):
return {
**base_fields,
"entity_name": 512,
"source_id": MILVUS_MAX_VARCHAR_BYTES,
}
if self.namespace.endswith("relationships"):
return {
**base_fields,
"src_id": 512,
"tgt_id": 512,
"source_id": MILVUS_MAX_VARCHAR_BYTES,
}
if self.namespace.endswith("chunks"):
return {**base_fields, "full_doc_id": 64}
return base_fields
def _get_migrated_metadata_field_limits(self) -> dict[str, int]:
if self.namespace.endswith("entities"):
return {
"content": MILVUS_MAX_VARCHAR_BYTES,
"source_id": MILVUS_MAX_VARCHAR_BYTES,
}
if self.namespace.endswith("relationships"):
return {
"content": MILVUS_MAX_VARCHAR_BYTES,
"source_id": MILVUS_MAX_VARCHAR_BYTES,
}
if self.namespace.endswith("chunks"):
return {"content": MILVUS_MAX_VARCHAR_BYTES}
return {}
@staticmethod
def _field_max_length(field: dict) -> int | None:
max_length = field.get("params", {}).get("max_length")
if max_length is None:
return None
try:
return int(max_length)
except (TypeError, ValueError):
return None
def _truncate_varchar_value(
self,
field_name: str,
value: Any,
record_id: str | None = None,
allow_identity_truncation: bool = False,
) -> Any:
limit = self._varchar_field_limits.get(field_name)
if limit is None or not isinstance(value, str):
return value
encoded = value.encode("utf-8")
if len(encoded) <= limit:
return value
# The primary key is never truncated: collapsing two ids into one would
# silently overwrite a row and orphan it from get_by_id/delete.
if field_name in MILVUS_PRIMARY_KEY_FIELDS:
raise ValueError(
f"[{self.workspace}] Milvus primary key '{field_name}' for record "
f"'{record_id or '<unknown>'}' exceeds {limit} bytes "
f"({len(encoded)} bytes); primary keys cannot be truncated"
)
# Other identity fields: reject on the live upsert path, but allow
# truncate-and-warn during migration so legacy data can be carried over
# without aborting the whole collection.
if (
field_name in MILVUS_IDENTITY_VARCHAR_FIELDS
and not allow_identity_truncation
):
raise ValueError(
f"[{self.workspace}] Milvus field '{field_name}' for record "
f"'{record_id or '<unknown>'}' exceeds {limit} bytes "
f"({len(encoded)} bytes); identity fields cannot be truncated"
)
# Cut to the byte budget on a valid UTF-8 boundary first.
truncated = encoded[:limit].decode("utf-8", errors="ignore")
# For separator-joined id lists, back off to the last separator that
# fits so we never persist a half id. Fall back to the raw byte cut when
# no separator fits (e.g. a single id longer than the limit).
if field_name in MILVUS_SEPARATOR_JOINED_FIELDS:
boundary = truncated.rfind(GRAPH_FIELD_SEP)
if boundary > 0:
truncated = truncated[:boundary]
logger.warning(
"[%s] Milvus field '%s' for record '%s' truncated from %d to %d bytes",
self.workspace,
field_name,
record_id or "<unknown>",
len(encoded),
len(truncated.encode("utf-8")),
)
return truncated
def _sanitize_varchar_fields(
self, row: dict[str, Any], allow_identity_truncation: bool = False
) -> dict[str, Any]:
record_id = str(row.get("id", "")) or None
return {
field_name: self._truncate_varchar_value(
field_name,
value,
record_id,
allow_identity_truncation=allow_identity_truncation,
)
for field_name, value in row.items()
}
def _normalize_migration_row(self, row: dict[str, Any]) -> dict[str, Any]:
normalized = dict(row)
metadata = normalized.pop("$meta", None)
if isinstance(metadata, dict):
for field_name, value in metadata.items():
# Explicit nullable fields can hold None while the real value
# still lives in $meta (schema-drift rows), so backfill on None
# rather than mere key presence.
if normalized.get(field_name) is None:
normalized[field_name] = value
# Migration carries pre-existing rows: non-primary identity fields are
# truncated-and-warned rather than rejected (see _truncate_varchar_value).
return self._sanitize_varchar_fields(normalized, allow_identity_truncation=True)
def _get_index_params(self):
"""Get IndexParams in a version-compatible way"""
try:
# Try to use client's prepare_index_params method (most common)
if hasattr(self._client, "prepare_index_params"):
return self._client.prepare_index_params()
except Exception:
pass
try:
# Try to import IndexParams from different possible locations
from pymilvus.client.prepare import IndexParams # type: ignore
return IndexParams()
except ImportError:
pass
try:
from pymilvus.client.types import IndexParams # type: ignore
return IndexParams()
except ImportError:
pass
try:
from pymilvus import IndexParams # type: ignore
return IndexParams()
except ImportError:
pass
# If all else fails, return None to use fallback method
return None
def _create_scalar_index_fallback(self, field_name: str, index_type: str):
"""Fallback method to create scalar index using direct API"""
# Skip unsupported index types
if index_type == "SORTED":
logger.info(
f"[{self.workspace}] Skipping SORTED index for {field_name} (not supported in this Milvus version)"
)
return
try:
self._client.create_index(
collection_name=self.final_namespace,
field_name=field_name,
index_params={"index_type": index_type},
)
logger.debug(
f"[{self.workspace}] Created {field_name} index using fallback method"
)
except Exception as e:
logger.info(
f"[{self.workspace}] Could not create {field_name} index using fallback method: {e}"
)
def _create_indexes_after_collection(self):
"""Create indexes after collection is created"""
# Build vector index using index configuration
# Use compatibility helper to get IndexParams
index_params_for_vector = self._get_index_params()
vector_index_params = self.index_config.build_index_params(
index_params_for_vector, field_name="vector"
)
# Re-raise exceptions to surface vector index creation failures
if isinstance(vector_index_params, dict):
self._client.create_index(
collection_name=self.final_namespace,
field_name=vector_index_params["field_name"],
index_params={
"index_type": vector_index_params["index_type"],
"metric_type": vector_index_params["metric_type"],
"params": vector_index_params["params"],
},
)
else:
self._client.create_index(
collection_name=self.final_namespace,
index_params=vector_index_params,
)
logger.debug(
f"[{self.workspace}] Created vector index with config: {self.index_config.to_dict()}"
)
# Create scalar indexes based on namespace
# Wrap scalar index creation in try-except to allow graceful degradation
try:
# Try to get IndexParams in a version-compatible way
scalar_index_params = self._get_index_params()
if scalar_index_params is not None:
# Create scalar indexes based on namespace
if self.namespace.endswith("entities"):
# Create indexes for entity fields
try:
entity_name_index = self._get_index_params()
entity_name_index.add_index(
field_name="entity_name", index_type="INVERTED"
)
self._client.create_index(
collection_name=self.final_namespace,
index_params=entity_name_index,
)
except Exception as e:
logger.debug(
f"[{self.workspace}] IndexParams method failed for entity_name: {e}"
)
self._create_scalar_index_fallback("entity_name", "INVERTED")
elif self.namespace.endswith("relationships"):
# Create indexes for relationship fields
try:
src_id_index = self._get_index_params()
src_id_index.add_index(
field_name="src_id", index_type="INVERTED"
)
self._client.create_index(
collection_name=self.final_namespace,
index_params=src_id_index,
)
except Exception as e:
logger.debug(
f"[{self.workspace}] IndexParams method failed for src_id: {e}"
)
self._create_scalar_index_fallback("src_id", "INVERTED")
try:
tgt_id_index = self._get_index_params()
tgt_id_index.add_index(
field_name="tgt_id", index_type="INVERTED"
)
self._client.create_index(
collection_name=self.final_namespace,
index_params=tgt_id_index,
)
except Exception as e:
logger.debug(
f"[{self.workspace}] IndexParams method failed for tgt_id: {e}"
)
self._create_scalar_index_fallback("tgt_id", "INVERTED")
elif self.namespace.endswith("chunks"):
# Create indexes for chunk fields
try:
doc_id_index = self._get_index_params()
doc_id_index.add_index(
field_name="full_doc_id", index_type="INVERTED"
)
self._client.create_index(
collection_name=self.final_namespace,
index_params=doc_id_index,
)
except Exception as e:
logger.debug(
f"[{self.workspace}] IndexParams method failed for full_doc_id: {e}"
)
self._create_scalar_index_fallback("full_doc_id", "INVERTED")
else:
# Fallback to direct API calls if IndexParams is not available
logger.info(
f"[{self.workspace}] IndexParams not available, using fallback methods for {self.namespace}"
)
# Create scalar indexes using fallback
if self.namespace.endswith("entities"):
self._create_scalar_index_fallback("entity_name", "INVERTED")
elif self.namespace.endswith("relationships"):
self._create_scalar_index_fallback("src_id", "INVERTED")
self._create_scalar_index_fallback("tgt_id", "INVERTED")
elif self.namespace.endswith("chunks"):
self._create_scalar_index_fallback("full_doc_id", "INVERTED")
logger.info(
f"[{self.workspace}] Created indexes for collection: {self.namespace}"
)
except Exception as e:
# Scalar index failures are logged as warnings (not critical)
logger.warning(
f"[{self.workspace}] Failed to create some scalar indexes for {self.namespace}: {e}"
)
def _get_required_fields_for_namespace(self) -> dict:
"""Get required core field definitions for current namespace"""
# Base fields (common to all types)
base_fields = {
"id": {"type": "VarChar", "is_primary": True},
"vector": {"type": "FloatVector"},
"created_at": {"type": "Int64"},
}
# Add specific fields based on namespace
if self.namespace.endswith("entities"):
specific_fields = {
"entity_name": {"type": "VarChar"},
"content": {"type": "VarChar"},
"source_id": {"type": "VarChar"},
"file_path": {"type": "VarChar"},
}
elif self.namespace.endswith("relationships"):
specific_fields = {
"src_id": {"type": "VarChar"},
"tgt_id": {"type": "VarChar"},
"content": {"type": "VarChar"},
"source_id": {"type": "VarChar"},
"file_path": {"type": "VarChar"},
}
elif self.namespace.endswith("chunks"):
specific_fields = {
"full_doc_id": {"type": "VarChar"},
"content": {"type": "VarChar"},
"file_path": {"type": "VarChar"},
}
else:
specific_fields = {
"file_path": {"type": "VarChar"},
}
return {**base_fields, **specific_fields}
def _is_field_compatible(self, existing_field: dict, expected_config: dict) -> bool:
"""Check compatibility of a single field"""
field_name = existing_field.get("name", "unknown")
existing_type = existing_field.get("type")
expected_type = expected_config.get("type")
logger.debug(
f"[{self.workspace}] Checking field '{field_name}': existing_type={existing_type} (type={type(existing_type)}), expected_type={expected_type}"
)
# Convert DataType enum values to string names if needed
original_existing_type = existing_type
if hasattr(existing_type, "name"):
existing_type = existing_type.name
logger.debug(
f"[{self.workspace}] Converted enum to name: {original_existing_type} -> {existing_type}"
)
elif isinstance(existing_type, int):
# Map common Milvus internal type codes to type names for backward compatibility
type_mapping = {
21: "VarChar",
101: "FloatVector",
5: "Int64",
9: "Double",
}
mapped_type = type_mapping.get(existing_type, str(existing_type))
logger.debug(
f"[{self.workspace}] Mapped numeric type: {existing_type} -> {mapped_type}"
)
existing_type = mapped_type
# Normalize type names for comparison
type_aliases = {
"VARCHAR": "VarChar",
"String": "VarChar",
"FLOAT_VECTOR": "FloatVector",
"INT64": "Int64",
"BigInt": "Int64",
"DOUBLE": "Double",
"Float": "Double",
}
original_existing = existing_type
original_expected = expected_type
existing_type = type_aliases.get(existing_type, existing_type)
expected_type = type_aliases.get(expected_type, expected_type)
if original_existing != existing_type or original_expected != expected_type:
logger.debug(
f"[{self.workspace}] Applied aliases: {original_existing} -> {existing_type}, {original_expected} -> {expected_type}"
)
# Basic type compatibility check
type_compatible = existing_type == expected_type
logger.debug(
f"[{self.workspace}] Type compatibility for '{field_name}': {existing_type} == {expected_type} -> {type_compatible}"
)
if not type_compatible:
logger.warning(
f"[{self.workspace}] Type mismatch for field '{field_name}': expected {expected_type}, got {existing_type}"
)
return False
# Primary key check - be more flexible about primary key detection
if expected_config.get("is_primary"):
# Check multiple possible field names for primary key status
is_primary = (
existing_field.get("is_primary_key", False)
or existing_field.get("is_primary", False)
or existing_field.get("primary_key", False)
)
logger.debug(
f"[{self.workspace}] Primary key check for '{field_name}': expected=True, actual={is_primary}"
)
logger.debug(
f"[{self.workspace}] Raw field data for '{field_name}': {existing_field}"
)
# For ID field, be more lenient - if it's the ID field, assume it should be primary
if field_name == "id" and not is_primary:
logger.info(
f"[{self.workspace}] ID field '{field_name}' not marked as primary in existing collection, but treating as compatible"
)
# Don't fail for ID field primary key mismatch
elif not is_primary:
logger.warning(
f"[{self.workspace}] Primary key mismatch for field '{field_name}': expected primary key, but field is not primary"
)
return False
logger.debug(f"[{self.workspace}] Field '{field_name}' is compatible")
return True
def _check_vector_dimension(self, collection_info: dict):
"""Check vector dimension compatibility"""
current_dimension = self.embedding_func.embedding_dim
# Find vector field dimension
for field in collection_info.get("fields", []):
if field.get("name") == "vector":
field_type = field.get("type")
# Extract type name from DataType enum or string
type_name = None
if hasattr(field_type, "name"):
type_name = field_type.name
elif isinstance(field_type, str):
type_name = field_type
else:
type_name = str(field_type)
# Check if it's a vector type (supports multiple formats)
if type_name in ["FloatVector", "FLOAT_VECTOR"]:
existing_dimension = field.get("params", {}).get("dim")
# Convert both to int for comparison to handle type mismatches
# (Milvus API may return string "1024" vs int 1024)
try:
existing_dim_int = (
int(existing_dimension)
if existing_dimension is not None
else None
)
current_dim_int = (
int(current_dimension)
if current_dimension is not None
else None
)
except (TypeError, ValueError) as e:
logger.error(
f"[{self.workspace}] Failed to parse dimensions: existing={existing_dimension} (type={type(existing_dimension)}), "
f"current={current_dimension} (type={type(current_dimension)}), error={e}"
)
raise ValueError(
f"Invalid dimension values for collection '{self.final_namespace}': "
f"existing={existing_dimension}, current={current_dimension}"
) from e
if existing_dim_int != current_dim_int:
raise ValueError(
f"Vector dimension mismatch for collection '{self.final_namespace}': "
f"existing={existing_dim_int}, current={current_dim_int}"
)
logger.debug(
f"[{self.workspace}] Vector dimension check passed: {current_dim_int}"
)
return
# If no vector field found, this might be an old collection created with simple schema
logger.warning(
f"[{self.workspace}] Vector field not found in collection '{self.namespace}'. This might be an old collection created with simple schema."
)
logger.warning(
f"[{self.workspace}] Consider recreating the collection for optimal performance."
)
return
@staticmethod
def _has_vector_field(collection_info: dict) -> bool:
"""Return True when the collection exposes a 'vector' field.
Old simple-schema collections may lack a vector field entirely. Their
rows therefore carry no vector data, and copying them into the new
schema (whose vector field is required) would fail at insert time, so
callers use this to skip migration for such collections.
"""
return any(
field.get("name") == "vector" for field in collection_info.get("fields", [])
)
def _check_file_path_length_restriction(self, collection_info: dict) -> bool:
"""Check if collection has file_path length restrictions that need migration
Returns:
bool: True if migration is needed, False otherwise
"""
existing_fields = {
field["name"]: field for field in collection_info.get("fields", [])
}
# Check if file_path field exists and has length restrictions
if "file_path" in existing_fields:
file_path_field = existing_fields["file_path"]
# Get max_length from field params
max_length = file_path_field.get("params", {}).get("max_length")
if max_length and max_length < DEFAULT_MAX_FILE_PATH_LENGTH:
logger.info(
f"[{self.workspace}] Collection {self.namespace} has file_path max_length={max_length}, "
f"needs migration to {DEFAULT_MAX_FILE_PATH_LENGTH}"
)
return True
return False
def _check_metadata_schema_migration_needed(self, collection_info: dict) -> bool:
existing_fields = {
field["name"]: field for field in collection_info.get("fields", [])
}
for (
field_name,
expected_max_length,
) in self._get_migrated_metadata_field_limits().items():
existing_field = existing_fields.get(field_name)
if existing_field is None:
logger.info(
f"[{self.workspace}] Collection {self.namespace} missing explicit Milvus field '{field_name}', needs migration"
)
return True
if not self._is_field_compatible(existing_field, {"type": "VarChar"}):
logger.info(
f"[{self.workspace}] Collection {self.namespace} has incompatible Milvus field '{field_name}', needs migration"
)
return True
max_length = self._field_max_length(existing_field)
if max_length is not None and max_length < expected_max_length:
logger.info(
f"[{self.workspace}] Collection {self.namespace} has {field_name} max_length={max_length}, "
f"needs migration to {expected_max_length}"
)
return True
return False
def _check_schema_compatibility(self, collection_info: dict):
"""Check schema field compatibility and detect migration needs"""
existing_fields = {
field["name"]: field for field in collection_info.get("fields", [])
}
# Check if this is an old collection created with simple schema
has_vector_field = self._has_vector_field(collection_info)
if not has_vector_field:
logger.warning(
f"[{self.workspace}] Collection {self.namespace} appears to be created with old simple schema (no vector field)"
)
logger.warning(
f"[{self.workspace}] This collection will work but may have suboptimal performance"
)
logger.warning(
f"[{self.workspace}] Consider recreating the collection for optimal performance"
)
return
if self._check_file_path_length_restriction(
collection_info
) or self._check_metadata_schema_migration_needed(collection_info):
logger.info(
f"[{self.workspace}] Starting automatic migration for collection {self.namespace}"
)
self._migrate_collection_schema()
return
# For collections with vector field, check basic compatibility
# Only check for critical incompatibilities, not missing optional fields
critical_fields = {"id": {"type": "VarChar", "is_primary": True}}
incompatible_fields = []
for field_name, expected_config in critical_fields.items():
if field_name in existing_fields:
existing_field = existing_fields[field_name]
if not self._is_field_compatible(existing_field, expected_config):
incompatible_fields.append(
f"{field_name}: expected {expected_config['type']}, "
f"got {existing_field.get('type')}"
)
if incompatible_fields:
raise ValueError(
f"Critical schema incompatibility in collection '{self.final_namespace}': {incompatible_fields}"
)
# Get all expected fields for informational purposes
expected_fields = self._get_required_fields_for_namespace()
missing_fields = [
field for field in expected_fields if field not in existing_fields
]
if missing_fields:
logger.info(
f"[{self.workspace}] Collection {self.namespace} missing optional fields: {missing_fields}"
)
logger.info(
"These fields would be available in a newly created collection for better performance"
)
logger.debug(
f"[{self.workspace}] Schema compatibility check passed for {self.namespace}"
)
def _create_collection_with_schema(
self, collection_name: str, ignore_index_errors: bool = False
) -> None:
original_final_namespace = self.final_namespace
try:
self.final_namespace = collection_name
schema = self._create_schema_for_namespace()
self._client.create_collection(
collection_name=collection_name, schema=schema
)
try:
self._create_indexes_after_collection()
except Exception as index_error:
if not ignore_index_errors:
raise
logger.warning(
f"[{self.workspace}] Failed to create indexes for new collection: {index_error}"
)
finally:
self.final_namespace = original_final_namespace
def _recover_interrupted_inplace_migration(
self, target_collection_name: str
) -> str:
"""Recover from a crash/disconnect inside the in-place migration commit window.
An in-place migration vacates the source collection (rename to _old,
or the drop-source fallback) in Step 3 *before* promoting the temp
collection to the target name in Step 4. A failure between those steps
leaves the target name free but the migrated rows stranded in the temp
collection (and the pre-migration rows, if any, in _old). Both are
recoverable state, never scratch: dropping the temp collection here —
as the normal Step 1 reset and the failed-attempt cleanup would —
could destroy the only surviving copy.
Only call this for the in-place case (source == target); a suffix
migration never vacates its source. It is a no-op unless the target
name is actually missing.
Returns:
"promoted" - temp was renamed to the target; the migration is
complete and the caller should stop.
"restored" - the _old backup was renamed back to the target; the
source is restored and a fresh migration can re-run.
"none" - no interrupted commit detected; proceed normally.
"""
if self._client.has_collection(target_collection_name):
return "none"
temp_collection_name = f"{target_collection_name}_temp"
old_backup_name = f"{target_collection_name}_old"
if self._client.has_collection(temp_collection_name):
logger.warning(
f"[{self.workspace}] Resuming interrupted migration: promoting "
f"{temp_collection_name} -> {target_collection_name}"
)
self._client.rename_collection(temp_collection_name, target_collection_name)
# The migrated data is now safe under the target name; the _old
# backup (if any) is no longer the active copy, so release it.
if self._client.has_collection(old_backup_name):
try:
self._client.release_collection(old_backup_name)
except Exception as release_error:
logger.warning(
f"[{self.workspace}] Failed to release backup collection "
f"{old_backup_name}: {release_error}"
)
return "promoted"
if self._client.has_collection(old_backup_name):
logger.warning(
f"[{self.workspace}] Resuming interrupted migration: restoring "
f"{old_backup_name} -> {target_collection_name} (no migrated copy survived)"
)
self._client.rename_collection(old_backup_name, target_collection_name)
return "restored"
return "none"
def _migrate_collection_schema(
self,
source_collection_name: str | None = None,
target_collection_name: str | None = None,
):
"""Run the iterator-based migration, retrying transient connection failures.
Each attempt is idempotent: it starts by dropping the leftover _temp
collection and the source collection is never touched until the final
rename, so a failed attempt can always be re-run from scratch. A
connection-class failure leaves the current MilvusClient permanently
dead (see _rebuild_milvus_client), so every retry rebuilds the client
before re-running the attempt.
"""
attempt = 0
backoff = self._migration_retry_backoff
needs_client_rebuild = False
while True:
try:
if needs_client_rebuild:
self._rebuild_milvus_client()
needs_client_rebuild = False
return self._migrate_collection_schema_attempt(
source_collection_name=source_collection_name,
target_collection_name=target_collection_name,
)
except Exception as e:
if not self._is_retryable_connection_error(e):
raise
attempt += 1
if attempt > self._migration_max_retries:
logger.error(
f"[{self.workspace}] Migration of {self.namespace} failed after "
f"{attempt} attempt(s) due to connection errors"
)
raise
needs_client_rebuild = True
logger.warning(
f"[{self.workspace}] Migration attempt "
f"{attempt}/{self._migration_max_retries + 1} for {self.namespace} "
f"failed with a connection error: {e}. "
f"Rebuilding Milvus client and retrying in {backoff:.0f}s"
)
time.sleep(backoff)
backoff = min(
backoff * MILVUS_MIGRATION_RETRY_BACKOFF_MULTIPLIER,
self._migration_retry_max_backoff,
)
def _migrate_collection_schema_attempt(
self,
source_collection_name: str | None = None,
target_collection_name: str | None = None,
):
source_collection_name = source_collection_name or self.final_namespace
target_collection_name = target_collection_name or self.final_namespace
temp_collection_name = f"{target_collection_name}_temp"
original_final_namespace = self.final_namespace
is_inplace = source_collection_name == target_collection_name
iterator = None
# Once an in-place migration has vacated its source (Step 3), the temp
# collection holds the only migrated copy and must not be dropped by
# the failure cleanup below — a later attempt or startup recovers it.
commit_phase = False
# True once we explicitly loaded a suffix migration's legacy source, so
# the failure cleanup can release it again (in-place sources are the
# active collection and are left as-is).
source_loaded = False
try:
logger.info(
f"[{self.workspace}] Starting iterator-based schema migration for {self.namespace}: "
f"{source_collection_name} -> {target_collection_name}"
)
# A previous attempt may have failed inside the in-place commit
# window (after the source was vacated). Finish that commit instead
# of restarting the copy, which would drop the recovery copy.
if is_inplace:
recovery = self._recover_interrupted_inplace_migration(
target_collection_name
)
if recovery == "promoted":
self.final_namespace = target_collection_name
return
# "restored": the source is back, fall through to a clean copy.
# "none": nothing to recover, proceed normally.
logger.info(
f"[{self.workspace}] Step 1: Creating temporary collection: {temp_collection_name}"
)
if self._client.has_collection(temp_collection_name):
self._client.drop_collection(temp_collection_name)
self._create_collection_with_schema(
temp_collection_name, ignore_index_errors=True
)
# The temp collection is deliberately NOT loaded here: insert does
# not require a loaded collection, and loading it would keep every
# migrated row's growing segments in query-node memory for the
# whole bulk copy (nearly doubling the collection's footprint on
# the server). The final collection is loaded after the rename via
# _ensure_collection_loaded.
logger.info(
f"[{self.workspace}] Step 2: Copying data using query_iterator from: {source_collection_name}"
)
# query_iterator issues a server-side query, which requires the
# source collection to be loaded. An in-place source is the active
# collection and is already loaded, but a legacy/suffix source is
# typically NOT loaded (it is an old backup), so the iterator setup
# fails with "collection not loaded" (code 101). Load it explicitly
# (idempotent); on a successful migration the source becomes the
# backup and is released again from memory at the end.
self._client.load_collection(source_collection_name)
# Only track suffix loads: an in-place source is the active
# collection and must not be released on failure.
source_loaded = not is_inplace
try:
iterator = self._client.query_iterator(
collection_name=source_collection_name,
batch_size=self._migration_iterator_batch_size,
output_fields=["*"],
)
logger.debug(f"[{self.workspace}] Query iterator created successfully")
except Exception as iterator_error:
logger.error(
f"[{self.workspace}] Failed to create query iterator: {iterator_error}"
)
raise
total_migrated = 0
batch_number = 1
rows_since_flush = 0
while True:
try:
batch_data = iterator.next()
if not batch_data:
# No more data available
break
sanitized_batch_data = [
self._normalize_migration_row(row) for row in batch_data
]
insert_batches = self._build_upsert_batches(
sanitized_batch_data,
max_payload_bytes=self._max_upsert_payload_bytes,
max_records_per_batch=self._max_upsert_records_per_batch,
)
try:
for insert_batch_number, (
records_batch,
estimated_bytes,
) in enumerate(insert_batches, 1):
logger.debug(
f"[{self.workspace}] Milvus migration insert batch "
f"{batch_number}.{insert_batch_number}/{len(insert_batches)}: "
f"records={len(records_batch)}, estimated_payload_bytes={estimated_bytes}"
)
self._client.insert(
collection_name=temp_collection_name,
data=records_batch,
)
total_migrated += len(batch_data)
rows_since_flush += len(batch_data)
logger.info(
f"[{self.workspace}] Iterator batch {batch_number}: "
f"processed {len(batch_data)} records, total migrated: {total_migrated}"
)
batch_number += 1
# Seal the accumulated insert buffer to bound the
# data-node memory and block until the flush returns,
# giving the server back-pressure during a large copy.
if (
self._migration_flush_interval_rows > 0
and rows_since_flush >= self._migration_flush_interval_rows
):
logger.info(
f"[{self.workspace}] Flushing temp collection after "
f"{rows_since_flush} rows (total migrated: {total_migrated})"
)
self._client.flush(temp_collection_name)
rows_since_flush = 0
# Optional throttle to let a small server breathe.
if self._migration_batch_sleep > 0:
time.sleep(self._migration_batch_sleep)
except Exception as batch_error:
logger.error(
f"[{self.workspace}] Failed to insert iterator batch {batch_number}: {batch_error}"
)
raise
except Exception as next_error:
logger.error(
f"[{self.workspace}] Iterator next() failed at batch {batch_number}: {next_error}"
)
raise
# Final flush so the last unsealed insert buffer is persisted and
# released before the collection is promoted and loaded.
if self._migration_flush_interval_rows > 0 and total_migrated > 0:
logger.info(
f"[{self.workspace}] Final flush of temp collection ({total_migrated} rows migrated)"
)
self._client.flush(temp_collection_name)
if total_migrated > 0:
logger.info(
f"[{self.workspace}] Successfully migrated {total_migrated} records using iterator"
)
else:
logger.info(
f"[{self.workspace}] No data found in original collection, migration completed"
)
backup_collection_name: str | None = None
if is_inplace:
logger.info(
f"[{self.workspace}] Step 3: Rename origin collection to {source_collection_name}_old"
)
# Entering the commit window: from here the source is vacated,
# so the temp collection becomes the recovery copy and must
# survive a failure rather than being cleaned up as scratch.
commit_phase = True
old_backup_name = f"{source_collection_name}_old"
try:
# Drop a stale backup from a previous migration first:
# rename_collection cannot overwrite an existing name, and
# a failed rename here falls back to dropping the source
# collection outright (losing the backup entirely).
if self._client.has_collection(old_backup_name):
logger.info(
f"[{self.workspace}] Dropping stale backup collection {old_backup_name}"
)
self._client.drop_collection(old_backup_name)
self._client.rename_collection(
source_collection_name, old_backup_name
)
backup_collection_name = old_backup_name
except Exception as rename_error:
try:
logger.warning(
f"[{self.workspace}] Try to drop origin collection instead"
)
self._client.drop_collection(source_collection_name)
except Exception as e:
logger.error(
f"[{self.workspace}] Rename operation failed: {rename_error}"
)
raise e
elif self._client.has_collection(target_collection_name):
raise RuntimeError(
f"Target collection already exists: {target_collection_name}"
)
else:
# Suffix migration: the legacy source collection stays in
# place as the backup.
backup_collection_name = source_collection_name
logger.info(
f"[{self.workspace}] Step 4: Renaming collection {temp_collection_name} -> {target_collection_name}"
)
try:
self._client.rename_collection(
temp_collection_name, target_collection_name
)
logger.info(f"[{self.workspace}] Rename operation completed")
except Exception as rename_error:
if source_collection_name == target_collection_name:
logger.error(
f"[{self.workspace}] Rename operation failed: {rename_error}"
)
else:
logger.error(
f"[{self.workspace}] Target rename operation failed: {rename_error}"
)
raise RuntimeError(
f"Failed to rename collection: {rename_error}"
) from rename_error
self.final_namespace = target_collection_name
# The backup collection (legacy source or renamed _old) inherits
# the loaded state of the pre-migration active collection, which
# would keep a full second copy of the data in query-node memory
# forever. Release it so the backup only occupies disk.
if backup_collection_name is not None:
try:
self._client.release_collection(backup_collection_name)
logger.info(
f"[{self.workspace}] Released backup collection {backup_collection_name} from memory"
)
except Exception as release_error:
logger.warning(
f"[{self.workspace}] Failed to release backup collection "
f"{backup_collection_name}: {release_error}"
)
except Exception as e:
self.final_namespace = original_final_namespace
logger.error(
f"[{self.workspace}] Iterator-based migration failed for {self.namespace}: {e}"
)
if commit_phase:
# The source has already been vacated, so the temp collection
# is the only migrated copy: keep it as recovery state. The
# next attempt (or startup) finishes the interrupted commit via
# _recover_interrupted_inplace_migration.
logger.warning(
f"[{self.workspace}] Migration failed after the source was vacated; "
f"keeping {temp_collection_name} as recovery state for the next attempt or startup"
)
else:
try:
if self._client and self._client.has_collection(
temp_collection_name
):
logger.info(
f"[{self.workspace}] Cleaning up failed migration temporary collection"
)
self._client.drop_collection(temp_collection_name)
except Exception as cleanup_error:
logger.warning(
f"[{self.workspace}] Failed to cleanup temporary collection: {cleanup_error}. "
f"The leftover temp collection will be dropped on the next migration attempt or startup"
)
# Release the suffix source we loaded above so a failed startup or
# retry does not leave a full backup resident in query-node memory.
# In-place sources are the active collection and are left as-is.
if source_loaded:
try:
self._client.release_collection(source_collection_name)
logger.info(
f"[{self.workspace}] Released source collection "
f"{source_collection_name} from memory after failed migration"
)
except Exception as release_error:
logger.warning(
f"[{self.workspace}] Failed to release source collection "
f"{source_collection_name}: {release_error}"
)
raise RuntimeError(
f"Iterator-based migration failed for collection {self.namespace}: {e}"
) from e
finally:
if iterator:
try:
iterator.close()
logger.debug(
f"[{self.workspace}] Query iterator closed successfully"
)
except Exception as close_error:
logger.warning(
f"[{self.workspace}] Failed to close query iterator: {close_error}"
)
def _validate_collection_compatibility(self):
"""Validate existing collection's dimension and schema compatibility"""
try:
collection_info = self._client.describe_collection(self.final_namespace)
# 1. Check vector dimension
self._check_vector_dimension(collection_info)
# 2. Check schema compatibility
self._check_schema_compatibility(collection_info)
logger.info(
f"[{self.workspace}] VectorDB Collection '{self.namespace}' compatibility validation passed"
)
except Exception as e:
logger.error(
f"[{self.workspace}] Collection compatibility validation failed for {self.namespace}: {e}"
)
raise
def _validate_collection_and_load(self) -> None:
try:
self._client.describe_collection(self.final_namespace)
self._validate_collection_compatibility()
except Exception as validation_error:
logger.error(
f"[{self.workspace}] CRITICAL ERROR: Collection '{self.namespace}' exists but validation failed!"
)
logger.error(
f"[{self.workspace}] This indicates potential data migration failure or schema incompatibility."
)
logger.error(f"[{self.workspace}] Validation error: {validation_error}")
logger.error(f"[{self.workspace}] MANUAL INTERVENTION REQUIRED:")
logger.error(
f"[{self.workspace}] 1. Check the existing collection schema and data integrity"
)
logger.error(f"[{self.workspace}] 2. Backup existing data if needed")
logger.error(
f"[{self.workspace}] 3. Manually resolve schema compatibility issues"
)
logger.error(
f"[{self.workspace}] 4. Consider recreating the collection using: lightrag-rebuild-vdb "
)
logger.error(
f"[{self.workspace}] Program execution stopped to prevent potential data loss."
)
raise RuntimeError(
f"Collection validation failed for '{self.final_namespace}'. "
f"Data migration failure detected. Manual intervention required to prevent data loss. "
f"Original error: {validation_error}"
)
try:
self._ensure_collection_loaded()
except Exception as load_error:
if not self._is_missing_vector_index_error(load_error):
raise
try:
self._repair_missing_vector_index()
self._ensure_collection_loaded()
logger.info(
f"[{self.workspace}] Repaired missing vector index for existing collection '{self.namespace}'"
)
except Exception as repair_error:
raise RuntimeError(
f"Index repair failed for collection '{self.final_namespace}'. "
f"Original error: {repair_error}"
) from repair_error
@staticmethod
def _is_missing_vector_index_error(error: Exception) -> bool:
"""Return True when the error indicates the collection lacks a vector index."""
error_message = str(error).lower()
return (
"no vector index" in error_message
or "please create index firstly" in error_message
)
def _repair_missing_vector_index(self):
"""Create indexes for an existing collection that is missing its vector index."""
logger.warning(
f"[{self.workspace}] Collection '{self.namespace}' is missing a vector index, attempting repair"
)
self._create_indexes_after_collection()
def _ensure_collection_loaded(self):
"""Ensure the collection is loaded into memory for search operations"""
try:
# Check if collection exists first
if not self._client.has_collection(self.final_namespace):
logger.error(
f"[{self.workspace}] Collection {self.namespace} does not exist"
)
raise ValueError(f"Collection {self.final_namespace} does not exist")
# Load the collection if it's not already loaded
# In Milvus, collections need to be loaded before they can be searched
self._client.load_collection(self.final_namespace)
# logger.debug(f"[{self.workspace}] Collection {self.namespace} loaded successfully")
except Exception as e:
logger.error(
f"[{self.workspace}] Failed to load collection {self.namespace}: {e}"
)
raise
def _create_collection_if_not_exist(self):
"""Create collection if not exists and check existing collection compatibility"""
try:
collection_exists = self._client.has_collection(self.final_namespace)
logger.info(
f"[{self.workspace}] VectorDB collection '{self.namespace}' exists check: {collection_exists}"
)
if collection_exists:
self._validate_collection_and_load()
return
legacy_collection_exists = (
self.legacy_namespace != self.final_namespace
and self._client.has_collection(self.legacy_namespace)
)
# Interrupted in-place commit recovery — MUST run before the legacy
# migration below. An in-place migration vacates its source (Step 3)
# before promoting the temp collection to the target name (Step 4);
# a crash in that window leaves the target name free with the
# completed copy stranded in {final}_temp and the pre-migration data
# in {final}_old. If a legacy migration ran first it would migrate
# the stale legacy collection over the target and drop {final}_temp
# as scratch (Step 1), silently losing every write made to the
# suffixed collection since the legacy split.
#
# {final}_old is the reliable marker that an in-place copy COMPLETED
# (it only ever exists once Step 3 renamed the source): when it is
# present, recover unconditionally. A lone {final}_temp next to a
# live legacy source is instead an aborted *partial* suffix copy and
# must be treated as scratch by the legacy migration — so only
# recover a lone temp when there is no legacy source to fall back to.
temp_collection_name = f"{self.final_namespace}_temp"
old_backup_name = f"{self.final_namespace}_old"
has_old_backup = self._client.has_collection(old_backup_name)
has_temp = self._client.has_collection(temp_collection_name)
if has_old_backup or (has_temp and not legacy_collection_exists):
recovery = self._recover_interrupted_inplace_migration(
self.final_namespace
)
if recovery in ("promoted", "restored"):
self._ensure_collection_loaded()
return
if legacy_collection_exists:
legacy_collection_info = self._client.describe_collection(
self.legacy_namespace
)
if not self._has_vector_field(legacy_collection_info):
# Old simple-schema collection with no vector field: its rows
# carry no vectors, so migrating them into the required-vector
# schema would fail at insert and block startup. Skip the
# migration and create a fresh suffixed collection instead.
logger.warning(
f"[{self.workspace}] Legacy collection '{self.legacy_namespace}' "
f"has no vector field (old simple schema); cannot migrate its rows "
f"into '{self.final_namespace}'. Creating a new collection instead."
)
else:
try:
self._check_vector_dimension(legacy_collection_info)
except ValueError as legacy_error:
logger.warning(
f"[{self.workspace}] Legacy collection '{self.legacy_namespace}' "
f"is not compatible with '{self.final_namespace}': {legacy_error}. "
f"Creating a new collection without migrating legacy vectors."
)
else:
self._migrate_collection_schema(
source_collection_name=self.legacy_namespace,
target_collection_name=self.final_namespace,
)
self._ensure_collection_loaded()
return
# Collection doesn't exist, create new collection
logger.info(f"[{self.workspace}] Creating new collection: {self.namespace}")
self._create_collection_with_schema(self.final_namespace)
self._ensure_collection_loaded()
logger.info(
f"[{self.workspace}] Successfully created Milvus collection: {self.namespace}"
)
except RuntimeError:
# Re-raise RuntimeError (validation failures) without modification
# These are critical errors that should stop execution
raise
except Exception as e:
# A transient connection failure must never trigger the
# force-create fallback below: dropping and recreating the
# collection on a flaky connection would destroy healthy data
# (or create an empty suffixed collection that permanently
# shadows an unmigrated legacy collection). Let it propagate so
# initialize() fails and can be retried.
if self._is_retryable_connection_error(e):
logger.error(
f"[{self.workspace}] Connection error in _create_collection_if_not_exist "
f"for {self.namespace}: {e}"
)
raise
logger.error(
f"[{self.workspace}] Error in _create_collection_if_not_exist for {self.namespace}: {e}"
)
# If there's any error (other than validation failure), try to force create the collection
logger.info(
f"[{self.workspace}] Attempting to force create collection {self.namespace}..."
)
try:
# Try to drop the collection first if it exists in a bad state
try:
if self._client.has_collection(self.final_namespace):
logger.info(
f"[{self.workspace}] Dropping potentially corrupted collection {self.namespace}"
)
self._client.drop_collection(self.final_namespace)
except Exception as drop_error:
logger.warning(
f"[{self.workspace}] Could not drop collection {self.namespace}: {drop_error}"
)
# Create fresh collection
self._create_collection_with_schema(self.final_namespace)
# Load the newly created collection
self._ensure_collection_loaded()
logger.info(
f"[{self.workspace}] Successfully force-created collection {self.namespace}"
)
except Exception as create_error:
logger.error(
f"[{self.workspace}] Failed to force-create collection {self.namespace}: {create_error}"
)
raise
def __post_init__(self):
validate_workspace(self.workspace)
self._validate_embedding_func()
# Extract MilvusIndexConfig parameters from vector_db_storage_cls_kwargs
#
# IMPORTANT: This approach allows Milvus index configuration via vector_db_storage_cls_kwargs,
# which is the RECOMMENDED method for framework integration (e.g., RAGAnything).
#
# All 11 index configuration parameters can be passed through vector_db_storage_cls_kwargs:
# - index_type, metric_type
# - hnsw_m, hnsw_ef_construction, hnsw_ef
# - sq_type, sq_refine, sq_refine_type, sq_refine_k
# - ivf_nlist, ivf_nprobe
#
# Example:
# LightRAG(
# vector_storage="MilvusVectorDBStorage",
# vector_db_storage_cls_kwargs={
# "cosine_better_than_threshold": 0.2,
# "index_type": "HNSW",
# "metric_type": "COSINE",
# "hnsw_m": 32,
# "hnsw_ef_construction": 256,
# }
# )
#
# Use MilvusIndexConfig.get_config_field_names() to dynamically extract valid parameters.
# This ensures we always stay in sync with the MilvusIndexConfig dataclass definition.
kwargs = self.global_config.get("vector_db_storage_cls_kwargs", {})
index_config_keys = MilvusIndexConfig.get_config_field_names()
index_config_params = {
k: v for k, v in kwargs.items() if k in index_config_keys
}
# Initialize index configuration (if not already set)
# Configuration priority: init params from kwargs > environment variables > defaults
if not hasattr(self, "index_config") or self.index_config is None:
self.index_config = MilvusIndexConfig(**index_config_params)
# Check for MILVUS_WORKSPACE environment variable first (higher priority)
# This allows administrators to force a specific workspace for all Milvus storage instances
milvus_workspace = os.environ.get("MILVUS_WORKSPACE")
if milvus_workspace and milvus_workspace.strip():
# Use environment variable value, overriding the passed workspace parameter
effective_workspace = milvus_workspace.strip()
logger.info(
f"Using MILVUS_WORKSPACE environment variable: '{effective_workspace}' (overriding '{self.workspace}/{self.namespace}')"
)
else:
# Use the workspace parameter passed during initialization
effective_workspace = self.workspace
if effective_workspace:
logger.debug(
f"Using passed workspace parameter: '{effective_workspace}'"
)
self.workspace = effective_workspace or ""
self.model_suffix = self._generate_collection_suffix()
if self.workspace:
self.legacy_namespace = f"{self.workspace}_{self.namespace}"
logger.debug(
f"Legacy namespace with workspace prefix: '{self.legacy_namespace}'"
)
else:
self.legacy_namespace = self.namespace
logger.debug(f"Legacy namespace (no workspace): '{self.legacy_namespace}'")
if self.model_suffix:
self.final_namespace = f"{self.legacy_namespace}_{self.model_suffix}"
logger.info(f"Milvus collection: {self.final_namespace}")
else:
self.final_namespace = self.legacy_namespace
logger.warning(
f"Milvus collection: {self.final_namespace} missing suffix. Please add model_name to embedding_func for proper model-based data isolation."
)
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
# Ensure created_at is in meta_fields
if "created_at" not in self.meta_fields:
self.meta_fields.add("created_at")
self._varchar_field_limits = self._get_varchar_field_limits_for_namespace()
# Initialize client as None - will be created in initialize() method
self._client = None
self._max_batch_size = self.global_config["embedding_batch_num"]
# Flush-time batching limits (see module-level DEFAULT_MILVUS_* constants).
# A non-positive value disables that splitting dimension.
self._max_upsert_payload_bytes = int(
os.getenv(
"MILVUS_UPSERT_MAX_PAYLOAD_BYTES",
str(DEFAULT_MILVUS_UPSERT_MAX_PAYLOAD_BYTES),
)
)
self._max_upsert_records_per_batch = int(
os.getenv(
"MILVUS_UPSERT_MAX_RECORDS_PER_BATCH",
str(DEFAULT_MILVUS_UPSERT_MAX_RECORDS_PER_BATCH),
)
)
self._max_delete_records_per_batch = int(
os.getenv(
"MILVUS_DELETE_MAX_RECORDS_PER_BATCH",
str(DEFAULT_MILVUS_DELETE_MAX_RECORDS_PER_BATCH),
)
)
if self._max_upsert_payload_bytes <= 0:
logger.warning(
f"MILVUS_UPSERT_MAX_PAYLOAD_BYTES={self._max_upsert_payload_bytes} is non-positive, disable payload-size splitting"
)
if self._max_upsert_records_per_batch <= 0:
logger.warning(
f"MILVUS_UPSERT_MAX_RECORDS_PER_BATCH={self._max_upsert_records_per_batch} is non-positive, disable upsert record-count splitting"
)
if self._max_delete_records_per_batch <= 0:
logger.warning(
f"MILVUS_DELETE_MAX_RECORDS_PER_BATCH={self._max_delete_records_per_batch} is non-positive, disable delete record-count splitting"
)
# Schema-migration retry knobs (see DEFAULT_MILVUS_MIGRATION_*
# constants). MILVUS_MIGRATION_MAX_RETRIES=0 restores fail-fast.
self._migration_max_retries = max(
0,
_get_env_int(
"MILVUS_MIGRATION_MAX_RETRIES", DEFAULT_MILVUS_MIGRATION_MAX_RETRIES
),
)
self._migration_retry_backoff = float(
os.getenv(
"MILVUS_MIGRATION_RETRY_BACKOFF",
str(DEFAULT_MILVUS_MIGRATION_RETRY_BACKOFF_SECONDS),
)
)
self._migration_retry_max_backoff = float(
os.getenv(
"MILVUS_MIGRATION_RETRY_MAX_BACKOFF",
str(DEFAULT_MILVUS_MIGRATION_RETRY_MAX_BACKOFF_SECONDS),
)
)
self._migration_iterator_batch_size = _get_env_int(
"MILVUS_MIGRATION_ITERATOR_BATCH_SIZE",
DEFAULT_MILVUS_MIGRATION_ITERATOR_BATCH_SIZE,
)
# Schema-migration memory back-pressure knobs (see the
# DEFAULT_MILVUS_MIGRATION_FLUSH_*/_BATCH_SLEEP_* constants).
self._migration_flush_interval_rows = max(
0,
_get_env_int(
"MILVUS_MIGRATION_FLUSH_INTERVAL_ROWS",
DEFAULT_MILVUS_MIGRATION_FLUSH_INTERVAL_ROWS,
),
)
self._migration_batch_sleep = max(
0.0,
float(
os.getenv(
"MILVUS_MIGRATION_BATCH_SLEEP",
str(DEFAULT_MILVUS_MIGRATION_BATCH_SLEEP_SECONDS),
)
),
)
self._initialized = False
# Deferred-embedding buffers and the per-namespace flush lock.
# The lock keys on final_namespace so two instances pointing at the
# same Milvus collection (e.g. when MILVUS_WORKSPACE env override is
# used) share a single writer lock. We construct it here in
# __post_init__ — not in initialize() — so any code path that
# touches the buffer before initialize() still has a valid lock.
self._pending_vector_docs: dict[str, _PendingVectorDoc] = {}
self._pending_vector_deletes: set[str] = set()
self._flush_lock = get_namespace_lock(
namespace=self.final_namespace, workspace=""
)
async def initialize(self):
"""Initialize Milvus collection"""
async with get_data_init_lock():
if self._initialized:
return
try:
# Create MilvusClient if not already created
if self._client is None:
self._client = self._create_milvus_client()
logger.debug(
f"[{self.workspace}] MilvusClient created successfully"
)
# Validate Milvus version compatibility with configured index
if self.index_config.index_type in INDEX_VERSION_REQUIREMENTS:
try:
server_version = self._client.get_server_version()
self.index_config.validate_milvus_version(server_version)
except Exception as version_error:
logger.error(
f"[{self.workspace}] Milvus version validation failed: {version_error}"
)
raise
# Create collection and check compatibility
self._create_collection_if_not_exist()
self._initialized = True
logger.info(
f"[{self.workspace}] Milvus collection '{self.namespace}' initialized successfully"
)
except Exception as e:
logger.error(
f"[{self.workspace}] Failed to initialize Milvus collection '{self.namespace}': {e}"
)
raise
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 (`get_by_id`/`get_by_ids`/`get_vectors_by_ids`)
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):
# _sanitize_varchar_fields already byte-truncates the stored
# `content` when it is a meta field; the pending doc keeps the full
# untruncated text so the embedding sees the complete chunk.
source = self._sanitize_varchar_fields(
{
"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)
# An upsert overrides any pending delete on the same id; installing
# a fresh _PendingVectorDoc instance 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]]:
"""Similarity search against the persisted Milvus collection.
Note: buffered-but-unflushed upserts are NOT visible to this method —
they exist only in `_pending_vector_docs` until `index_done_callback()`
embeds and writes them. Callers that need read-after-write visibility
for similarity search must run an explicit flush first.
"""
# Ensure collection is loaded before querying
self._ensure_collection_loaded()
# Use provided embedding or compute it
if query_embedding is not None:
embedding = [query_embedding] # Milvus expects a list of embeddings
else:
embedding = await self.embedding_func(
[query], context="query", _priority=DEFAULT_QUERY_PRIORITY
) # higher priority for query
# Include all meta_fields (created_at is now always included)
output_fields = list(self.meta_fields)
# Build search params from index config
search_params_base = self.index_config.build_search_params()
# Merge with metric type and radius threshold
search_params = {
"metric_type": self.index_config.metric_type,
"params": {
**search_params_base.get("params", {}),
"radius": self.cosine_better_than_threshold,
},
}
results = self._client.search(
collection_name=self.final_namespace,
data=embedding,
limit=top_k,
output_fields=output_fields,
search_params=search_params,
)
return [
{
**dp["entity"],
"id": dp["id"],
"distance": dp["distance"],
"created_at": dp.get("created_at"),
}
for dp in results[0]
]
@staticmethod
def _build_upsert_batches(
records: list[dict[str, Any]],
max_payload_bytes: int,
max_records_per_batch: int,
) -> list[tuple[list[dict[str, Any]], int]]:
"""Split upsert records into batches by estimated payload size and count.
The byte budget is the primary limiter: records accumulate until adding
the next one would exceed ``max_payload_bytes``, then a new batch starts.
Size is estimated by JSON-serializing each record; this overestimates the
actual gRPC protobuf size (a JSON float string is far longer than the 4
protobuf bytes it encodes), so the split stays conservatively below the
server limit and never underestimates.
A single record larger than the byte budget is emitted as its own batch
rather than raising: JSON overestimation means such a record's real
protobuf size is often still under Milvus' 64MB ceiling, so we let the
server be the final arbiter instead of failing client-side. Returns a
list of ``(batch, estimated_bytes)`` tuples (estimate used for logging).
"""
if not records:
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[dict[str, Any]], int]] = []
current_batch: list[dict[str, Any]] = []
# JSON array overhead ("[]")
current_estimated_bytes = 2
for record in records:
record_size = len(
json.dumps(
record,
ensure_ascii=False,
separators=(",", ":"),
default=str,
).encode("utf-8")
)
# If current batch not empty, a comma is needed before next element.
separator_overhead = 1 if current_batch else 0
next_batch_size = current_estimated_bytes + separator_overhead + record_size
if current_batch and (
len(current_batch) >= records_limit or next_batch_size > payload_limit
):
batches.append((current_batch, current_estimated_bytes))
current_batch = []
current_estimated_bytes = 2
next_batch_size = current_estimated_bytes + record_size
current_batch.append(record)
current_estimated_bytes = next_batch_size
if current_batch:
batches.append((current_batch, current_estimated_bytes))
return batches
async def index_done_callback(self) -> None:
"""Flush all buffered vector ops to Milvus before returning.
Contract: on a successful return, every previously buffered upsert
has been embedded and committed to the collection, and every buffered
delete has been issued — i.e. all pending vectors are durable in
Milvus (which persists automatically once written). On any embed-
or server-side failure this method raises and leaves both buffers
intact for the next callback to retry; the caller MUST NOT assume
clean persistence in that case.
"""
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 to Milvus.
Embedding runs *inside* this lock (not in `upsert` or lock-free):
it makes deferred embedding and bulk indexing 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.
"""
async with self._flush_lock:
if not self._pending_vector_docs and not self._pending_vector_deletes:
return
if self._client is None:
return
# Milvus requires the collection to be loaded before upsert/delete.
self._ensure_collection_loaded()
pending_docs = self._pending_vector_docs
pending_deletes = self._pending_vector_deletes
docs_to_embed: list[tuple[str, _PendingVectorDoc]] = [
(doc_id, pdoc)
for doc_id, pdoc in pending_docs.items()
if pdoc.vector is None
]
if docs_to_embed:
contents = [pdoc.content for _, pdoc in docs_to_embed]
batches = [
contents[i : i + self._max_batch_size]
for i in range(0, len(contents), self._max_batch_size)
]
logger.info(
f"[{self.workspace}] {self.namespace} flush: embedding "
f"{len(docs_to_embed)} vectors in {len(batches)} batch(es) "
f"(batch_num={self._max_batch_size})"
)
try:
embeddings_list = await asyncio.gather(
*[
self.embedding_func(batch, context="document")
for batch in batches
]
)
except Exception as e:
logger.error(
f"[{self.workspace}] Error embedding pending vector ops "
f"(upserts={len(docs_to_embed)}): {e}"
)
raise
embeddings = np.concatenate(embeddings_list)
if len(embeddings) != len(docs_to_embed):
raise RuntimeError(
f"[{self.workspace}] Embedding count mismatch: expected "
f"{len(docs_to_embed)}, got {len(embeddings)}"
)
for i, ((_, pdoc), embedding) in enumerate(
zip(docs_to_embed, embeddings), start=1
):
# Cache as float32 so a second flush after a server-side
# error doesn't re-embed, and so the upsert JSON payload
# stays compact (float32 serializes to a shorter string
# than float64, and Milvus stores FLOAT_VECTOR as float32
# anyway, so the cast is lossless).
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.
committed_ids: list[str] = list(pending_docs.keys())
# source was already byte-truncated in upsert(); no need to
# re-sanitize here (vector is not a VarChar field).
list_data: list[dict[str, Any]] = [
{
**pending_docs[doc_id].source,
"vector": pending_docs[doc_id].vector,
}
for doc_id in committed_ids
]
try:
if list_data:
# Split the upsert into batches that stay under the server-side
# 64MB gRPC message limit. Fail-fast: any batch failure raises
# immediately and the full buffer is retained for the next flush.
upsert_batches = self._build_upsert_batches(
list_data,
max_payload_bytes=self._max_upsert_payload_bytes,
max_records_per_batch=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}] Milvus upsert batch {batch_index}/{len(upsert_batches)}: "
f"records={len(records_batch)}, estimated_payload_bytes={estimated_bytes}"
)
self._client.upsert(
collection_name=self.final_namespace, data=records_batch
)
if pending_deletes:
# Chunk deletes by record count; pks are short strings so a
# count cap is enough to stay under the gRPC message limit.
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):
self._client.delete(
collection_name=self.final_namespace,
pks=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 committed_ids:
pending_docs.pop(doc_id, None)
pending_deletes.clear()
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 query
+ delete cannot interleave with an in-flight bulk upsert.
Server-side failures are re-raised (no log-and-swallow): the caller
decides whether to retry.
Buffer semantics — post-prune with caller short-circuit contract:
Matching pending upserts in ``_pending_vector_docs`` are
pruned **only after** the server-side query + delete
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.
Semantic note (deferred-buffer ↔ persisted divergence): pruning only
consults the *current* buffered ``src_id`` / ``tgt_id`` view; we do
not re-read the persisted row a buffered upsert is about to
overwrite. So if a pending upsert is rewriting an already-persisted
``rel-X-Y`` so that its new ``src_id`` / ``tgt_id`` matches
``entity_name`` while the persisted row's do not (or vice versa),
the persisted row will not be deleted by the server-side filter and
the pending overwrite is dropped — i.e. the final state can diverge
from the eager-flush ordering (upsert → flush → delete). Callers
that require eager-equivalent semantics should call
``index_done_callback()`` before ``delete_entity_relation``.
"""
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._client is None:
# No server state to mutate; buffer prune is the only
# delete intent we can record.
_prune_pending()
return
self._ensure_collection_loaded()
expr = f'src_id == "{entity_name}" or tgt_id == "{entity_name}"'
results = self._client.query(
collection_name=self.final_namespace,
filter=expr,
output_fields=["id"],
)
if not results:
# 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 = [item["id"] for item in results]
self._client.delete(collection_name=self.final_namespace, pks=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 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 get_by_id(self, id: str) -> dict[str, Any] | None:
"""Get vector data by its ID, with read-your-writes against the buffer."""
async with self._flush_lock:
if id in self._pending_vector_deletes:
return None
pending = self._pending_vector_docs.get(id)
if pending is not None:
doc = dict(pending.source)
doc["id"] = id
return doc
try:
# Ensure collection is loaded before querying
self._ensure_collection_loaded()
# Include all meta_fields (created_at is now always included) plus id
output_fields = list(self.meta_fields) + ["id"]
result = self._client.query(
collection_name=self.final_namespace,
filter=f'id == "{id}"',
output_fields=output_fields,
)
if not result or len(result) == 0:
return None
return result[0]
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["id"] = doc_id
buffered[doc_id] = doc
continue
remaining.append(doc_id)
result_map: dict[str, dict[str, Any]] = {}
if remaining:
try:
# Ensure collection is loaded before querying
self._ensure_collection_loaded()
# Include all meta_fields (created_at is now always included) plus id
output_fields = list(self.meta_fields) + ["id"]
id_list = '", "'.join(remaining)
filter_expr = f'id in ["{id_list}"]'
result = self._client.query(
collection_name=self.final_namespace,
filter=filter_expr,
output_fields=output_fields,
)
if result:
for row in result:
if not row:
continue
row_id = row.get("id")
if row_id is not None:
result_map[str(row_id)] = row
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 result_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 with `vector is None` trigger a lazy embed inside the
lock; the resulting vector is cached on the buffered `_PendingVectorDoc`
so the next flush won't re-embed the same content.
"""
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
):
# Cache float32 to match the flush path so the buffered
# vector dtype is uniform regardless of which path embedded.
pdoc.vector = np.array(embedding, dtype=np.float32).tolist()
result[doc_id] = pdoc.vector
await _cooperative_yield(i)
if not remaining:
return result
try:
self._ensure_collection_loaded()
id_list = '", "'.join(remaining)
filter_expr = f'id in ["{id_list}"]'
rows = self._client.query(
collection_name=self.final_namespace,
filter=filter_expr,
output_fields=["id", "vector"],
)
for item in rows or []:
if item and "vector" in item and "id" in item:
vector_data = item["vector"]
if isinstance(vector_data, np.ndarray):
vector_data = vector_data.tolist()
# Match get_by_ids: stringify the server-returned id so
# callers can index the dict by the original requested id.
result[str(item["id"])] = vector_data
return result
except Exception as e:
logger.error(
f"[{self.workspace}] Error retrieving vectors by IDs from {self.namespace}: {e}"
)
return result
async def finalize(self):
"""Flush pending vector ops, then release the Milvus gRPC channel.
Every other server-backed storage releases its client here (Neo4j
driver, Postgres pool, Mongo client, OpenSearch ClientManager); the
MilvusClient owns a gRPC channel that should be closed explicitly
too — ``close()`` is already used for that purpose by
``_rebuild_milvus_client``. We still fail loudly when a transient
bulk error left writes buffered — the caller must not believe
storage finalized cleanly.
"""
flush_error: Exception | None = None
try:
await self._flush_pending_vector_ops()
except Exception as e:
flush_error = e
# Release the gRPC channel after the flush so the flush can still use
# the client. Best-effort: close() on a dead channel is a no-op (see
# _rebuild_milvus_client). The connection is freed on every exit path,
# matching the close-on-release pattern of the other server-backed
# storages (Neo4j / Postgres / Mongo / OpenSearch).
if self._client is not None:
try:
self._client.close()
except Exception as close_error:
logger.warning(
f"[{self.workspace}] Failed to close Milvus client: {close_error}"
)
# Read the residual buffer sizes under the flush lock so the
# snapshot is consistent with any racing late-arriving mutator
# (cancellation paths can land an upsert/delete between the flush
# above and the post-mortem check below).
async with self._flush_lock:
pending_docs = len(self._pending_vector_docs)
pending_deletes = len(self._pending_vector_deletes)
if flush_error is not None:
raise RuntimeError(
f"[{self.workspace}] MilvusVectorDBStorage.finalize() flush raised; "
f"{pending_docs} pending upserts and {pending_deletes} pending "
f"deletes were left buffered (data lost)"
) from flush_error
if pending_docs or pending_deletes:
raise RuntimeError(
f"[{self.workspace}] MilvusVectorDBStorage.finalize() left "
f"{pending_docs} pending upserts and {pending_deletes} pending "
f"deletes buffered after final flush attempt (these writes have been lost)"
)
async def drop(self) -> dict[str, str]:
"""Drop all data from the Milvus collection. 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
``MilvusVectorDBStorage`` instances aliased onto the same
``final_namespace`` (multi-worker processes, or distinct
workspaces collapsed by ``MILVUS_WORKSPACE``) keep their own
buffers; a sibling whose prior flush failed and left buffers
intact will, on its next flush, upsert 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 gone;
# a concurrent flush would otherwise resurrect them.
self._pending_vector_docs.clear()
self._pending_vector_deletes.clear()
# Drop the collection and recreate it empty.
if self._client.has_collection(self.final_namespace):
self._client.drop_collection(self.final_namespace)
# Recreate an EMPTY collection. Do NOT route through
# _create_collection_if_not_exist here: with the suffixed
# collection now gone it would see the intentionally-kept legacy
# collection and re-run the legacy->suffixed migration, pulling
# the just-dropped rows back in. That makes drop() non-empty
# (clear_documents would leave stale legacy data behind) and
# forces a needless full migration on every rebuild/clear.
self._create_collection_with_schema(self.final_namespace)
self._ensure_collection_loaded()
logger.info(
f"[{self.workspace}] Process {os.getpid()} drop Milvus collection {self.namespace}"
)
return {"status": "success", "message": "data dropped"}
except Exception as e:
logger.error(
f"[{self.workspace}] Error dropping Milvus collection {self.namespace}: {e}"
)
return {"status": "error", "message": str(e)}