mirror of
https://github.com/langgenius/dify.git
synced 2026-08-24 12:32:54 -04:00
c1e94f8ee7
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
438 lines
16 KiB
Python
438 lines
16 KiB
Python
import json
|
|
import logging
|
|
import math
|
|
import re
|
|
from collections.abc import Mapping
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from configs import dify_config
|
|
from core.model_manager import ModelManager
|
|
from core.rag.datasource.vdb.vector_factory import Vector
|
|
from core.rag.datasource.vdb.vector_type import VectorType
|
|
from core.rag.embedding.cached_embedding import CacheEmbedding
|
|
from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType
|
|
from core.rag.models.document import Document
|
|
from enums import CloudPlan, DeploymentEdition
|
|
from extensions.ext_redis import redis_client
|
|
from graphon.model_runtime.entities.model_entities import ModelType
|
|
from models.dataset import Dataset
|
|
from services.billing_service import BillingService
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_MEBIBYTE = 1024 * 1024
|
|
_FLOAT32_BYTES = 4
|
|
_TIDB_VECTOR_COPIES = 2
|
|
_TIDB_POINT_OVERHEAD_BYTES = 3584
|
|
_WATERMARK_LOCK_TIMEOUT_SECONDS = 5
|
|
_WATERMARK_TTL_SECONDS = 30 * 60
|
|
_ERROR_PATTERN = re.compile(
|
|
r"Vector storage is estimated to reach (?P<estimated>\d+) MB after this upload, "
|
|
r"exceeding the (?P<limit>\d+) MB limit of the current plan\."
|
|
)
|
|
|
|
VECTOR_SPACE_ADMISSION_ERROR_CODE = "vector_space_estimate_exceeded"
|
|
|
|
|
|
class VectorSpaceAdmissionError(ValueError):
|
|
def __init__(self, message: str):
|
|
self.description = message
|
|
super().__init__(message)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class VectorStorageWorkload:
|
|
text_points: int
|
|
summary_points: int
|
|
probe_text: str | None
|
|
|
|
@property
|
|
def total_points(self) -> int:
|
|
return self.text_points + self.summary_points
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class VectorSpaceAdmissionErrorDetails:
|
|
estimated_mb: int
|
|
plan_limit_mb: int
|
|
|
|
|
|
def estimate_tidb_storage_bytes(point_count: int, dimension: int) -> int:
|
|
"""Estimate TiDB row and columnar storage for vector points."""
|
|
return point_count * (dimension * _FLOAT32_BYTES * _TIDB_VECTOR_COPIES + _TIDB_POINT_OVERHEAD_BYTES)
|
|
|
|
|
|
def parse_vector_space_estimate_limits(value: str) -> dict[CloudPlan, int]:
|
|
limits: dict[CloudPlan, int] = {}
|
|
for item in value.split(","):
|
|
plan_name, separator, raw_limit = item.strip().partition(":")
|
|
if not separator:
|
|
raise ValueError(f"Invalid vector-space estimate limit: {item!r}")
|
|
try:
|
|
plan = CloudPlan(plan_name)
|
|
limit = int(raw_limit)
|
|
except (TypeError, ValueError) as error:
|
|
raise ValueError(f"Invalid vector-space estimate limit: {item!r}") from error
|
|
if limit <= 0 or plan in limits:
|
|
raise ValueError(f"Invalid vector-space estimate limit: {item!r}")
|
|
limits[plan] = limit
|
|
if set(limits) != set(CloudPlan):
|
|
raise ValueError(f"Invalid vector-space estimate limits: {value!r}; include sandbox, professional, and team")
|
|
return limits
|
|
|
|
|
|
def format_vector_space_admission_error(estimated_mb: int, plan_limit_mb: int) -> str:
|
|
return (
|
|
f"Vector storage is estimated to reach {estimated_mb} MB after this upload, "
|
|
f"exceeding the {plan_limit_mb} MB limit of the current plan."
|
|
)
|
|
|
|
|
|
def get_vector_space_admission_error_details(error: str | None) -> VectorSpaceAdmissionErrorDetails | None:
|
|
if not error or not (match := _ERROR_PATTERN.fullmatch(error)):
|
|
return None
|
|
return VectorSpaceAdmissionErrorDetails(
|
|
estimated_mb=int(match.group("estimated")),
|
|
plan_limit_mb=int(match.group("limit")),
|
|
)
|
|
|
|
|
|
def get_vector_space_admission_error_fields(error: str | None) -> dict[str, str | int | None]:
|
|
details = get_vector_space_admission_error_details(error)
|
|
return {
|
|
"error_code": VECTOR_SPACE_ADMISSION_ERROR_CODE if details else None,
|
|
"estimated_vector_space_mb": details.estimated_mb if details else None,
|
|
"vector_space_limit_mb": details.plan_limit_mb if details else None,
|
|
}
|
|
|
|
|
|
def build_document_workload(
|
|
doc_form: str,
|
|
documents: list[Document],
|
|
*,
|
|
include_summaries: bool,
|
|
) -> VectorStorageWorkload:
|
|
# V1 estimates text vectors only; attachments are excluded.
|
|
texts: list[str] = []
|
|
for document in documents:
|
|
if doc_form == IndexStructureType.PARENT_CHILD_INDEX:
|
|
texts.extend(
|
|
child.page_content
|
|
for child in document.children or []
|
|
if child.page_content and child.page_content.strip()
|
|
)
|
|
elif document.page_content and document.page_content.strip():
|
|
texts.append(document.page_content)
|
|
|
|
summary_points = 0
|
|
if include_summaries and doc_form != IndexStructureType.QA_INDEX:
|
|
summary_points = sum(1 for document in documents if document.page_content and document.page_content.strip())
|
|
|
|
return VectorStorageWorkload(
|
|
text_points=len(texts),
|
|
summary_points=summary_points,
|
|
probe_text=texts[0] if texts else None,
|
|
)
|
|
|
|
|
|
def build_pipeline_workload(
|
|
chunk_structure: str,
|
|
chunks: Any,
|
|
*,
|
|
include_summaries: bool,
|
|
) -> VectorStorageWorkload:
|
|
# V1 estimates chunk text only; file and image metadata are excluded.
|
|
texts: list[str] = []
|
|
summary_points = 0
|
|
|
|
if chunk_structure == IndexStructureType.QA_INDEX:
|
|
for chunk in _items(chunks, "qa_chunks"):
|
|
question = _field(chunk, "question")
|
|
if isinstance(question, str) and question.strip():
|
|
texts.append(question)
|
|
elif chunk_structure == IndexStructureType.PARENT_CHILD_INDEX:
|
|
for chunk in _items(chunks, "parent_child_chunks"):
|
|
parent_content = _field(chunk, "parent_content")
|
|
if include_summaries and isinstance(parent_content, str) and parent_content.strip():
|
|
summary_points += 1
|
|
for child in _field(chunk, "child_contents") or []:
|
|
if isinstance(child, str) and child.strip():
|
|
texts.append(child)
|
|
else:
|
|
raw_chunks = chunks if isinstance(chunks, list) else _items(chunks, "general_chunks")
|
|
for chunk in raw_chunks:
|
|
content = chunk if isinstance(chunk, str) else _field(chunk, "content")
|
|
if isinstance(content, str) and content.strip():
|
|
texts.append(content)
|
|
if include_summaries:
|
|
summary_points += 1
|
|
|
|
return VectorStorageWorkload(
|
|
text_points=len(texts),
|
|
summary_points=summary_points,
|
|
probe_text=texts[0] if texts else None,
|
|
)
|
|
|
|
|
|
def _field(value: Any, name: str) -> Any:
|
|
if isinstance(value, Mapping):
|
|
return value.get(name)
|
|
return getattr(value, name, None) # guard-ignore: no-new-getattr -- supports validated chunk models
|
|
|
|
|
|
def _items(value: Any, name: str) -> list[Any]:
|
|
items = _field(value, name)
|
|
return list(items) if items else []
|
|
|
|
|
|
class VectorSpaceAdmissionService:
|
|
"""Cloud-only pre-write guard for unusually large TiDB vector workloads."""
|
|
|
|
def __init__(self) -> None:
|
|
self._dimension_by_dataset: dict[str, int] = {}
|
|
self._plan_by_tenant: dict[str, CloudPlan | None] = {}
|
|
|
|
def ensure_document_can_be_indexed(
|
|
self,
|
|
*,
|
|
dataset: Dataset,
|
|
document_id: str,
|
|
doc_form: str,
|
|
documents: list[Document],
|
|
include_summaries: bool,
|
|
session: Session,
|
|
) -> None:
|
|
self._ensure_can_write(
|
|
dataset=dataset,
|
|
document_id=document_id,
|
|
workload=build_document_workload(
|
|
doc_form,
|
|
documents,
|
|
include_summaries=include_summaries,
|
|
),
|
|
session=session,
|
|
)
|
|
|
|
def ensure_pipeline_can_be_indexed(
|
|
self,
|
|
*,
|
|
dataset: Dataset,
|
|
document_id: str,
|
|
chunk_structure: str,
|
|
chunks: Any,
|
|
include_summaries: bool,
|
|
session: Session,
|
|
) -> None:
|
|
self._ensure_can_write(
|
|
dataset=dataset,
|
|
document_id=document_id,
|
|
workload=build_pipeline_workload(
|
|
chunk_structure,
|
|
chunks,
|
|
include_summaries=include_summaries,
|
|
),
|
|
session=session,
|
|
)
|
|
|
|
def _ensure_can_write(
|
|
self,
|
|
*,
|
|
dataset: Dataset,
|
|
document_id: str,
|
|
workload: VectorStorageWorkload,
|
|
session: Session,
|
|
) -> None:
|
|
if (
|
|
dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD
|
|
or dataset.indexing_technique != IndexTechniqueType.HIGH_QUALITY
|
|
or workload.total_points == 0
|
|
or workload.probe_text is None
|
|
):
|
|
return
|
|
if Vector.resolve_vector_type(dataset, session=session) != VectorType.TIDB_ON_QDRANT:
|
|
return
|
|
|
|
plan = self._get_plan(dataset.tenant_id)
|
|
if plan is None:
|
|
return
|
|
estimate_limit_mb = parse_vector_space_estimate_limits(
|
|
dify_config.TIDB_ON_QDRANT_ESTIMATED_STORAGE_LIMITS_MB
|
|
).get(plan)
|
|
if estimate_limit_mb is None:
|
|
return
|
|
|
|
current_usage_mb, plan_limit_mb = self._get_usage_and_limit_mb(dataset.tenant_id)
|
|
dimension = self._get_embedding_dimension(dataset, workload.probe_text)
|
|
estimate_bytes = math.ceil(estimate_tidb_storage_bytes(workload.total_points, dimension))
|
|
document_estimated_mb = estimate_bytes / _MEBIBYTE
|
|
base_usage_bytes, projected_usage_bytes = self._reserve_projected_usage(
|
|
tenant_id=dataset.tenant_id,
|
|
document_id=document_id,
|
|
current_usage_bytes=math.ceil(current_usage_mb * _MEBIBYTE),
|
|
document_estimate_bytes=estimate_bytes,
|
|
estimate_limit_bytes=estimate_limit_mb * _MEBIBYTE,
|
|
)
|
|
base_usage_mb = base_usage_bytes / _MEBIBYTE
|
|
projected_usage_mb = projected_usage_bytes / _MEBIBYTE
|
|
if projected_usage_bytes > estimate_limit_mb * _MEBIBYTE:
|
|
logger.warning(
|
|
"TiDB vector-space admission rejected tenant_id=%s document_id=%s plan=%s "
|
|
"points=%s dimension=%s current_usage_mb=%s document_estimated_mb=%s "
|
|
"watermark_base_usage_mb=%s projected_usage_mb=%s plan_limit_mb=%s estimate_limit_mb=%s",
|
|
dataset.tenant_id,
|
|
document_id,
|
|
plan,
|
|
workload.total_points,
|
|
dimension,
|
|
current_usage_mb,
|
|
document_estimated_mb,
|
|
base_usage_mb,
|
|
projected_usage_mb,
|
|
plan_limit_mb,
|
|
estimate_limit_mb,
|
|
)
|
|
raise VectorSpaceAdmissionError(
|
|
format_vector_space_admission_error(math.ceil(projected_usage_mb), plan_limit_mb)
|
|
)
|
|
|
|
logger.info(
|
|
"TiDB vector-space admission allowed tenant_id=%s document_id=%s plan=%s "
|
|
"points=%s dimension=%s current_usage_mb=%s document_estimated_mb=%s "
|
|
"watermark_base_usage_mb=%s projected_usage_mb=%s estimate_limit_mb=%s",
|
|
dataset.tenant_id,
|
|
document_id,
|
|
plan,
|
|
workload.total_points,
|
|
dimension,
|
|
current_usage_mb,
|
|
document_estimated_mb,
|
|
base_usage_mb,
|
|
projected_usage_mb,
|
|
estimate_limit_mb,
|
|
)
|
|
|
|
def _get_usage_and_limit_mb(self, tenant_id: str) -> tuple[float, int]:
|
|
try:
|
|
vector_space = BillingService.get_vector_space(tenant_id)
|
|
current_usage_mb = float(vector_space["size"])
|
|
plan_limit_mb = int(vector_space["limit"])
|
|
except Exception as error:
|
|
raise VectorSpaceAdmissionError(
|
|
"Unable to verify vector storage usage right now. Please try again later."
|
|
) from error
|
|
return current_usage_mb, plan_limit_mb
|
|
|
|
def _reserve_projected_usage(
|
|
self,
|
|
*,
|
|
tenant_id: str,
|
|
document_id: str,
|
|
current_usage_bytes: int,
|
|
document_estimate_bytes: int,
|
|
estimate_limit_bytes: int,
|
|
) -> tuple[int, int]:
|
|
watermark_key = f"tenant:{tenant_id}:vector_space_estimate_watermark"
|
|
lock_key = f"{watermark_key}:lock"
|
|
|
|
try:
|
|
with redis_client.lock(
|
|
lock_key,
|
|
timeout=_WATERMARK_LOCK_TIMEOUT_SECONDS,
|
|
blocking_timeout=_WATERMARK_LOCK_TIMEOUT_SECONDS,
|
|
):
|
|
raw_state = redis_client.get(watermark_key)
|
|
stored_usage_bytes = 0
|
|
document_ids: set[str] = set()
|
|
if raw_state:
|
|
state = json.loads(raw_state)
|
|
stored_usage_bytes = state.get("projected_usage_bytes")
|
|
raw_document_ids = state.get("document_ids")
|
|
if (
|
|
type(stored_usage_bytes) is not int
|
|
or stored_usage_bytes < 0
|
|
or not isinstance(raw_document_ids, list)
|
|
or not all(isinstance(item, str) for item in raw_document_ids)
|
|
):
|
|
raise ValueError("Invalid vector-space estimate watermark")
|
|
document_ids = set(raw_document_ids)
|
|
|
|
base_usage_bytes = max(current_usage_bytes, stored_usage_bytes)
|
|
projected_usage_bytes = base_usage_bytes
|
|
if document_id not in document_ids:
|
|
projected_usage_bytes += document_estimate_bytes
|
|
|
|
if projected_usage_bytes <= estimate_limit_bytes:
|
|
document_ids.add(document_id)
|
|
redis_client.setex(
|
|
watermark_key,
|
|
_WATERMARK_TTL_SECONDS,
|
|
json.dumps(
|
|
{
|
|
"projected_usage_bytes": projected_usage_bytes,
|
|
"document_ids": sorted(document_ids),
|
|
},
|
|
separators=(",", ":"),
|
|
),
|
|
)
|
|
|
|
return base_usage_bytes, projected_usage_bytes
|
|
except Exception as error:
|
|
raise VectorSpaceAdmissionError(
|
|
"Unable to reserve estimated vector storage right now. Please try again later."
|
|
) from error
|
|
|
|
def _get_plan(self, tenant_id: str) -> CloudPlan | None:
|
|
if tenant_id in self._plan_by_tenant:
|
|
return self._plan_by_tenant[tenant_id]
|
|
try:
|
|
billing_info = BillingService.get_info(tenant_id, exclude_vector_space=True)
|
|
except Exception as error:
|
|
raise VectorSpaceAdmissionError(
|
|
"Unable to verify the subscription plan right now. Please try again later."
|
|
) from error
|
|
|
|
plan = None
|
|
if billing_info["enabled"]:
|
|
try:
|
|
plan = CloudPlan(billing_info["subscription"]["plan"])
|
|
except ValueError:
|
|
logger.warning(
|
|
"Skipping TiDB vector-space admission for unknown plan tenant_id=%s plan=%s",
|
|
tenant_id,
|
|
billing_info["subscription"]["plan"],
|
|
)
|
|
self._plan_by_tenant[tenant_id] = plan
|
|
return plan
|
|
|
|
def _get_embedding_dimension(self, dataset: Dataset, probe_text: str) -> int:
|
|
cached_dimension = self._dimension_by_dataset.get(dataset.id)
|
|
if cached_dimension is not None:
|
|
return cached_dimension
|
|
|
|
model_manager = ModelManager.for_tenant(tenant_id=dataset.tenant_id)
|
|
if dataset.embedding_model_provider:
|
|
model_instance = model_manager.get_model_instance(
|
|
tenant_id=dataset.tenant_id,
|
|
provider=dataset.embedding_model_provider,
|
|
model_type=ModelType.TEXT_EMBEDDING,
|
|
model=dataset.embedding_model,
|
|
)
|
|
else:
|
|
model_instance = model_manager.get_default_model_instance(
|
|
tenant_id=dataset.tenant_id,
|
|
model_type=ModelType.TEXT_EMBEDDING,
|
|
)
|
|
|
|
embeddings = CacheEmbedding(model_instance).embed_documents([probe_text])
|
|
if not embeddings or not embeddings[0]:
|
|
raise VectorSpaceAdmissionError(
|
|
"Unable to estimate vector storage for this document. Please try again later."
|
|
)
|
|
|
|
dimension = len(embeddings[0])
|
|
self._dimension_by_dataset[dataset.id] = dimension
|
|
return dimension
|