diff --git a/docs/en/guides/01-configuration.md b/docs/en/guides/01-configuration.md
index 67a4080a38..fcc41f5719 100644
--- a/docs/en/guides/01-configuration.md
+++ b/docs/en/guides/01-configuration.md
@@ -1209,7 +1209,7 @@ Vector database storage configuration
| Parameter | Type | Description | Default |
|-----------|------|-------------|---------|
-| `backend` | str | VectorDB backend type: 'local' (file-based), 'http' (remote service), 'volcengine' (cloud VikingDB), 'vikingdb' (private deployment), 'cuvs' (local storage + GPU dense search), 'qdrant', or 'opengauss' | "local" |
+| `backend` | str | VectorDB backend type: 'local' (file-based), 'http' (remote service), 'volcengine' (cloud VikingDB), 'vikingdb' (private deployment), 'cuvs' (local storage + GPU dense search), 'qdrant', 'milvus', or 'opengauss' | "local" |
| `name` | str | VectorDB collection name | "context" |
| `url` | str | Remote service URL for 'http' type (e.g., 'http://localhost:5000') | null |
| `project_name` | str | Project name (alias project) | "default" |
@@ -1220,6 +1220,7 @@ Vector database storage configuration
| `vikingdb` | object | 'vikingdb' type private deployment configuration | - |
| `cuvs` | object | NVIDIA cuVS configuration for the 'cuvs' backend and the opt-in memory-aware auto mode on 'local'; see the [cuVS guide](./16-cuvs.md) | - |
| `qdrant` | object | 'qdrant' type Qdrant configuration | - |
+| `milvus` | object | 'milvus' type Milvus or Zilliz Cloud configuration | - |
| `opengauss` | object | 'opengauss' native vector backend configuration | - |
Default local mode
@@ -1254,6 +1255,37 @@ Supports cloud-deployed VikingDB on Volcengine
```
+
+Milvus
+
+Install the `milvus` optional extra before using this backend. The default `uri`
+uses Milvus Lite and stores data in a local `milvus.db` file. Use an HTTP URI for
+self-hosted Milvus, or a cloud endpoint plus `token` for Zilliz Cloud.
+
+```json
+{
+ "storage": {
+ "vectordb": {
+ "name": "context",
+ "backend": "milvus",
+ "project": "default",
+ "distance_metric": "cosine",
+ "dimension": 1024,
+ "milvus": {
+ "uri": "./milvus.db",
+ "token": null,
+ "db_name": null,
+ "consistency_level": "Session"
+ }
+ }
+ }
+}
+```
+
+For a self-hosted server, set `"uri": "http://localhost:19530"`. For Zilliz Cloud,
+set `"uri"` to the cloud endpoint and provide `"token"`.
+
+
openGauss
diff --git a/docs/zh/guides/01-configuration.md b/docs/zh/guides/01-configuration.md
index bfe1080785..033072d575 100644
--- a/docs/zh/guides/01-configuration.md
+++ b/docs/zh/guides/01-configuration.md
@@ -1181,7 +1181,7 @@ RAGFS 默认使用 Rust binding 模式,通过 Rust 实现直接访问文件系
| 参数 | 类型 | 说明 | 默认值 |
|------|------|------|--------|
-| `backend` | str | VectorDB 后端类型: 'local'(基于文件), 'http'(远程服务), 'volcengine'(云上 VikingDB), 'vikingdb'(私有部署), 'cuvs'(本地存储 + GPU dense search), 'qdrant' 或 'opengauss' | "local" |
+| `backend` | str | VectorDB 后端类型: 'local'(基于文件), 'http'(远程服务), 'volcengine'(云上 VikingDB), 'vikingdb'(私有部署), 'cuvs'(本地存储 + GPU dense search), 'qdrant'、'milvus' 或 'opengauss' | "local" |
| `name` | str | VectorDB 的集合名称 | "context" |
| `url` | str | 'http' 类型的远程服务 URL(例如 'http://localhost:5000') | null |
| `project_name` | str | 项目名称(别名 project) | "default" |
@@ -1192,6 +1192,7 @@ RAGFS 默认使用 Rust binding 模式,通过 Rust 实现直接访问文件系
| `vikingdb` | object | 'vikingdb' 类型的私有部署配置 | - |
| `cuvs` | object | NVIDIA cuVS 配置,也用于在 'local' 下显式开启显存感知自动模式,参见 [cuVS 使用指南](./16-cuvs.md) | - |
| `qdrant` | object | 'qdrant' 类型的 Qdrant 配置 | - |
+| `milvus` | object | 'milvus' 类型的 Milvus 或 Zilliz Cloud 配置 | - |
| `opengauss` | object | 'opengauss' 原生向量后端配置 | - |
默认使用本地模式
@@ -1226,6 +1227,37 @@ RAGFS 默认使用 Rust binding 模式,通过 Rust 实现直接访问文件系
```
+
+Milvus
+
+使用该后端前需要安装 `milvus` 可选依赖。默认 `uri` 使用 Milvus Lite,
+并把数据写入本地 `milvus.db` 文件。自托管 Milvus 使用 HTTP URI;
+Zilliz Cloud 使用云端 endpoint 并配置 `token`。
+
+```json
+{
+ "storage": {
+ "vectordb": {
+ "name": "context",
+ "backend": "milvus",
+ "project": "default",
+ "distance_metric": "cosine",
+ "dimension": 1024,
+ "milvus": {
+ "uri": "./milvus.db",
+ "token": null,
+ "db_name": null,
+ "consistency_level": "Session"
+ }
+ }
+ }
+}
+```
+
+如果连接自托管服务,可设置 `"uri": "http://localhost:19530"`。如果使用
+Zilliz Cloud,把 `"uri"` 设置为云端 endpoint,并填写 `"token"`。
+
+
openGauss
diff --git a/openviking/storage/vectordb_adapters/README.md b/openviking/storage/vectordb_adapters/README.md
index 4158306c47..a3f40099c0 100644
--- a/openviking/storage/vectordb_adapters/README.md
+++ b/openviking/storage/vectordb_adapters/README.md
@@ -146,6 +146,32 @@
1. `backend`: 填写 Adapter 类的完整 Python 路径(例如 `my_project.adapters.MyAdapter`)。
2. `custom_params`: 这是一个字典,你可以放入任何自定义参数,Adapter 的 `from_config` 方法可以通过 `config.custom_params` 获取这些值。
+内置 Milvus 后端可直接使用 registry 名称,并通过 `milvus` 配置块传递连接信息:
+
+```json
+{
+ "storage": {
+ "vectordb": {
+ "backend": "milvus",
+ "name": "context",
+ "project": "default",
+ "distance_metric": "cosine",
+ "dimension": 1024,
+ "milvus": {
+ "uri": "./milvus.db",
+ "token": null,
+ "db_name": null,
+ "consistency_level": "Session"
+ }
+ }
+ }
+}
+```
+
+`uri` 默认为 Milvus Lite 本地文件;也可以设置为自建 Milvus 服务
+(如 `http://localhost:19530`)或 Zilliz Cloud endpoint,认证统一使用
+`token` 字段。
+
---
@@ -238,4 +264,4 @@ class ThirdPartyCollectionAdapter(CollectionAdapter):
- `backend=thirdparty` 可正常初始化。
- create 后可完成 upsert/get/query/delete/count 全流程。
- 不改上层业务调用方式即可参与 `find/search` 检索链路。
-- 后端差异全部封装在 adapter 层。
\ No newline at end of file
+- 后端差异全部封装在 adapter 层。
diff --git a/openviking/storage/vectordb_adapters/__init__.py b/openviking/storage/vectordb_adapters/__init__.py
index bb33a3a368..b97ea18e40 100644
--- a/openviking/storage/vectordb_adapters/__init__.py
+++ b/openviking/storage/vectordb_adapters/__init__.py
@@ -6,6 +6,7 @@
from .factory import create_collection_adapter
from .http_adapter import HttpCollectionAdapter
from .local_adapter import CuVSCollectionAdapter, LocalCollectionAdapter
+from .milvus_adapter import MilvusCollectionAdapter
from .opengauss_adapter import OpenGaussCollectionAdapter
from .qdrant_adapter import QdrantCollectionAdapter
from .vikingdb_private_adapter import VikingDBPrivateCollectionAdapter
@@ -16,6 +17,7 @@
"LocalCollectionAdapter",
"CuVSCollectionAdapter",
"HttpCollectionAdapter",
+ "MilvusCollectionAdapter",
"OpenGaussCollectionAdapter",
"QdrantCollectionAdapter",
"VolcengineCollectionAdapter",
diff --git a/openviking/storage/vectordb_adapters/factory.py b/openviking/storage/vectordb_adapters/factory.py
index 24e570e6c7..bde47f2248 100644
--- a/openviking/storage/vectordb_adapters/factory.py
+++ b/openviking/storage/vectordb_adapters/factory.py
@@ -9,6 +9,7 @@
from .base import CollectionAdapter
from .http_adapter import HttpCollectionAdapter
from .local_adapter import CuVSCollectionAdapter, LocalCollectionAdapter
+from .milvus_adapter import MilvusCollectionAdapter
from .opengauss_adapter import OpenGaussCollectionAdapter
from .qdrant_adapter import QdrantCollectionAdapter
from .vikingdb_private_adapter import VikingDBPrivateCollectionAdapter
@@ -18,6 +19,7 @@
"local": LocalCollectionAdapter,
"cuvs": CuVSCollectionAdapter,
"http": HttpCollectionAdapter,
+ "milvus": MilvusCollectionAdapter,
"opengauss": OpenGaussCollectionAdapter,
"qdrant": QdrantCollectionAdapter,
"volcengine": VolcengineCollectionAdapter,
diff --git a/openviking/storage/vectordb_adapters/milvus_adapter.py b/openviking/storage/vectordb_adapters/milvus_adapter.py
new file mode 100644
index 0000000000..d20a99d766
--- /dev/null
+++ b/openviking/storage/vectordb_adapters/milvus_adapter.py
@@ -0,0 +1,1646 @@
+# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd.
+# SPDX-License-Identifier: AGPL-3.0
+"""Milvus-backed vector collection adapter."""
+
+from __future__ import annotations
+
+import datetime as dt
+import json
+import math
+import re
+from typing import Any, Dict, Iterable, List, Optional, Sequence
+
+from openviking.storage.expr import (
+ And,
+ Contains,
+ Eq,
+ FilterExpr,
+ In,
+ Or,
+ PathScope,
+ Range,
+ RawDSL,
+ TimeRange,
+)
+from openviking.storage.vectordb.collection.collection import Collection, ICollection
+from openviking.storage.vectordb.collection.result import (
+ AggregateResult,
+ DataItem,
+ FetchDataInCollectionResult,
+ SearchItemResult,
+ SearchResult,
+)
+from openviking.storage.vectordb.index.index import IIndex
+from openviking.storage.vectordb.store.data import DeltaRecord
+from openviking.storage.vectordb_adapters.base import CollectionAdapter
+from openviking_cli.utils import get_logger
+
+logger = get_logger(__name__)
+
+_DEFAULT_URI = "./milvus.db"
+_DEFAULT_TIMEOUT_SECONDS = 30
+_DEFAULT_QUERY_LIMIT = 10_000
+_MILVUS_MAX_COLLECTION_NAME_LENGTH = 255
+_MILVUS_VARCHAR_MAX_LENGTH = 65_535
+_ID_MAX_LENGTH = 512
+_URI_MAX_LENGTH = 4096
+_FIELD_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
+_COLLECTION_NAME_RE = re.compile(r"[^A-Za-z0-9_]+")
+_VECTOR_FIELD_TYPES = {"vector", "float_vector"}
+_LIST_STRING_FIELD_TYPES = {"list", "array"}
+_STRING_FIELD_TYPES = {"string", "path", "text", "date_time"}
+_INT_FIELD_TYPES = {"int64", "int32", "integer", "long"}
+_FLOAT_FIELD_TYPES = {"float", "double"}
+_BOOL_FIELD_TYPES = {"bool", "boolean"}
+_META_PROPERTY_KEY = "openviking_meta"
+_INDEX_META_PROPERTY_PREFIX = "openviking_index_"
+_META_COLLECTION_NAME = "ov_openviking_milvus_meta"
+_META_VECTOR_FIELD = "meta_vector"
+
+
+def _import_pymilvus():
+ try:
+ import pymilvus # type: ignore # noqa: PLC0415
+
+ return pymilvus
+ except ImportError as exc: # pragma: no cover - exercised only without optional driver
+ raise ImportError(
+ "The Milvus backend requires pymilvus with Milvus Lite support. "
+ "Install the `openviking[milvus]` optional extra."
+ ) from exc
+
+
+def _safe_collection_name(*parts: Any, prefix: str = "ov") -> str:
+ raw = "_".join(str(part or "") for part in parts)
+ normalized = _COLLECTION_NAME_RE.sub("_", raw).strip("_")
+ if not normalized:
+ normalized = "default"
+ if normalized[0].isdigit():
+ normalized = f"{prefix}_{normalized}"
+ elif prefix and not normalized.startswith(f"{prefix}_"):
+ normalized = f"{prefix}_{normalized}"
+ if len(normalized) <= _MILVUS_MAX_COLLECTION_NAME_LENGTH:
+ return normalized
+
+ import hashlib
+
+ digest = hashlib.sha1(normalized.encode("utf-8")).hexdigest()[:10]
+ keep = _MILVUS_MAX_COLLECTION_NAME_LENGTH - len(digest) - 1
+ return f"{normalized[:keep]}_{digest}"
+
+
+def _normalize_distance(distance: str) -> str:
+ value = (distance or "cosine").strip().lower()
+ if value not in {"cosine", "l2", "ip"}:
+ raise ValueError(
+ f"Milvus backend supports only cosine, l2, and ip distance metrics; got {distance!r}"
+ )
+ return value
+
+
+def _milvus_metric(distance: str) -> str:
+ return {"cosine": "COSINE", "l2": "L2", "ip": "IP"}[_normalize_distance(distance)]
+
+
+def _json_default(value: Any) -> Any:
+ if isinstance(value, (dt.datetime, dt.date)):
+ return value.isoformat()
+ return str(value)
+
+
+def _json_dumps(value: Any) -> str:
+ return json.dumps(value, ensure_ascii=False, sort_keys=True, default=_json_default)
+
+
+def _json_loads(value: Any) -> Any:
+ if not isinstance(value, str):
+ return value
+ try:
+ return json.loads(value)
+ except (TypeError, ValueError):
+ return value
+
+
+def _truncate_utf8(value: str, byte_limit: int) -> str:
+ encoded = value.encode("utf-8")
+ if len(encoded) <= byte_limit:
+ return value
+ cut = byte_limit
+ while cut > 0 and (encoded[cut] & 0xC0) == 0x80:
+ cut -= 1
+ return encoded[:cut].decode("utf-8")
+
+
+def _encode_scope_roots(value: Any) -> str:
+ roots = value if isinstance(value, list) else [value]
+ normalized = [str(root) for root in roots if root is not None]
+ return "\n" + "\n".join(normalized) + "\n" if normalized else "\n"
+
+
+def _sparse_dot(left: Optional[Dict[str, float]], right: Optional[Dict[str, float]]) -> float:
+ if not left or not right:
+ return 0.0
+ total = 0.0
+ for key, raw_value in left.items():
+ try:
+ total += float(raw_value) * float(right.get(key, 0.0))
+ except (TypeError, ValueError):
+ continue
+ return total
+
+
+def _coerce_datetime_value(value: Any) -> Any:
+ if isinstance(value, dt.datetime):
+ if value.tzinfo is None:
+ value = value.replace(tzinfo=dt.timezone.utc)
+ return value.isoformat()
+ if isinstance(value, dt.date):
+ return value.isoformat()
+ return value
+
+
+def _format_number(value: Any) -> str:
+ if isinstance(value, bool):
+ raise ValueError("Boolean values are not valid numeric filter operands")
+ number = float(value)
+ if not math.isfinite(number):
+ raise ValueError(f"Invalid numeric filter value: {value!r}")
+ if number.is_integer():
+ return str(int(number))
+ return format(number, ".12g")
+
+
+def _quote_value(value: Any) -> str:
+ value = _coerce_datetime_value(value)
+ if value is None:
+ return "null"
+ if isinstance(value, bool):
+ return "true" if value else "false"
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
+ return _format_number(value)
+ text = (
+ str(value)
+ .replace("\\", "\\\\")
+ .replace("\n", "\\n")
+ .replace("\r", "\\r")
+ .replace("\t", "\\t")
+ .replace('"', '\\"')
+ )
+ return f'"{text}"'
+
+
+def _format_value_list(values: Iterable[Any]) -> str:
+ return "[" + ", ".join(_quote_value(value) for value in values) + "]"
+
+
+def _score_from_hit(hit: Dict[str, Any], distance_metric: str) -> float:
+ raw_score = (
+ hit.get("score")
+ if hit.get("score") is not None
+ else hit.get("distance", hit.get("_distance", 0.0))
+ )
+ try:
+ score = float(raw_score)
+ except (TypeError, ValueError):
+ return 0.0
+ if not math.isfinite(score):
+ return 0.0
+ if distance_metric == "cosine":
+ return 1.0 - score
+ if distance_metric == "l2":
+ return 1.0 / (1.0 + max(score, 0.0))
+ return score
+
+
+class MilvusIndex(IIndex):
+ """Metadata-only logical index facade for Milvus."""
+
+ def __init__(self, collection: "MilvusCollection", index_name: str, meta: Dict[str, Any]):
+ super().__init__(meta=meta)
+ self._collection = collection
+ self._index_name = index_name
+ self._meta = dict(meta)
+
+ def upsert_data(self, delta_list: List[DeltaRecord]):
+ raise NotImplementedError("MilvusIndex.upsert_data is managed at collection level")
+
+ def delete_data(self, delta_list: List[DeltaRecord]):
+ raise NotImplementedError("MilvusIndex.delete_data is managed at collection level")
+
+ def search(
+ self,
+ query_vector: Optional[List[float]],
+ limit: int = 10,
+ filters: Optional[Dict[str, Any]] = None,
+ sparse_raw_terms: Optional[List[str]] = None,
+ sparse_values: Optional[List[float]] = None,
+ ):
+ raise NotImplementedError("MilvusIndex.search is not exposed via raw index interface")
+
+ def aggregate(self, filters: Optional[Dict[str, Any]] = None):
+ raise NotImplementedError("MilvusIndex.aggregate is not exposed via raw index interface")
+
+ def update(
+ self, scalar_index: Optional[Dict[str, Any]] = None, description: Optional[str] = None
+ ):
+ self._collection.update_index(
+ index_name=self._index_name,
+ scalar_index=scalar_index,
+ description=description,
+ )
+ self._meta = self._collection.get_index_meta_data(self._index_name) or self._meta
+
+ def get_meta_data(self):
+ return dict(self._meta)
+
+ def close(self):
+ return None
+
+ def drop(self):
+ self._collection.drop_index(self._index_name)
+
+
+class MilvusCollection(ICollection):
+ """A single OpenViking collection stored in Milvus."""
+
+ INTERNAL_PATH_FIELDS = {
+ "parent_uri": "path",
+ "scope_roots": "string",
+ "uri_depth": "int64",
+ }
+
+ def __init__(
+ self,
+ *,
+ client: Any,
+ logical_collection_name: str,
+ physical_collection_name: str,
+ project_name: str,
+ dense_vector_name: str,
+ sparse_vector_name: str,
+ distance_metric: str,
+ timeout_seconds: int,
+ meta: Optional[Dict[str, Any]] = None,
+ ) -> None:
+ super().__init__()
+ self._client = client
+ self._logical_collection_name = logical_collection_name
+ self._physical_collection_name = physical_collection_name
+ self._project_name = project_name
+ self._dense_vector_name = dense_vector_name
+ self._sparse_vector_name = sparse_vector_name
+ self._distance_metric = _normalize_distance(distance_metric)
+ self._timeout_seconds = int(timeout_seconds)
+ self._meta = dict(meta or {})
+ self._field_types = self._build_field_type_map(self._meta)
+ self._varchar_lengths = self._build_varchar_length_map()
+ self._vector_dim = self._extract_vector_dim(self._meta)
+
+ @property
+ def collection_name(self) -> str:
+ return self._physical_collection_name
+
+ @staticmethod
+ def _extract_vector_dim(meta: Dict[str, Any]) -> int:
+ for field in meta.get("Fields", []) or []:
+ if str(field.get("FieldType") or "").lower() in _VECTOR_FIELD_TYPES:
+ try:
+ return int(field.get("Dim") or 0)
+ except (TypeError, ValueError):
+ return 0
+ return 0
+
+ @classmethod
+ def _build_field_type_map(cls, meta: Dict[str, Any]) -> Dict[str, str]:
+ mapping: Dict[str, str] = {}
+ for field in meta.get("Fields", []) or []:
+ name = field.get("FieldName")
+ field_type = field.get("FieldType")
+ if name and field_type:
+ mapping[str(name)] = str(field_type).lower()
+ mapping.setdefault("id", "string")
+ mapping.setdefault("vector", "vector")
+ mapping.setdefault("sparse_vector", "json")
+ mapping.update(cls.INTERNAL_PATH_FIELDS)
+ return mapping
+
+ def _build_varchar_length_map(self) -> Dict[str, int]:
+ lengths: Dict[str, int] = {
+ "id": _ID_MAX_LENGTH,
+ "uri": _URI_MAX_LENGTH,
+ "parent_uri": _URI_MAX_LENGTH,
+ "scope_roots": _MILVUS_VARCHAR_MAX_LENGTH,
+ }
+ for field_name, field_type in self._field_types.items():
+ if field_type in _STRING_FIELD_TYPES:
+ lengths.setdefault(field_name, _MILVUS_VARCHAR_MAX_LENGTH)
+ return lengths
+
+ def collection_exists(self) -> bool:
+ return bool(
+ self._client.has_collection(
+ collection_name=self._physical_collection_name,
+ timeout=self._timeout_seconds,
+ )
+ )
+
+ def _collection_properties(self) -> Dict[str, Any]:
+ try:
+ desc = self._client.describe_collection(
+ collection_name=self._physical_collection_name,
+ timeout=self._timeout_seconds,
+ )
+ except Exception:
+ return {}
+ props = desc.get("properties") if isinstance(desc, dict) else None
+ return dict(props or {})
+
+ def _ensure_meta_collection(self) -> None:
+ try:
+ if self._client.has_collection(
+ collection_name=_META_COLLECTION_NAME,
+ timeout=self._timeout_seconds,
+ ):
+ return
+ pymilvus = _import_pymilvus()
+ DataType = pymilvus.DataType
+ schema = self._client.create_schema(auto_id=False, enable_dynamic_field=False)
+ schema.add_field(
+ field_name="id",
+ datatype=DataType.VARCHAR,
+ is_primary=True,
+ max_length=_MILVUS_MAX_COLLECTION_NAME_LENGTH,
+ )
+ schema.add_field(
+ field_name="meta_json",
+ datatype=DataType.VARCHAR,
+ max_length=_MILVUS_VARCHAR_MAX_LENGTH,
+ )
+ schema.add_field(field_name="indexes_json", datatype=DataType.JSON, nullable=True)
+ schema.add_field(field_name=_META_VECTOR_FIELD, datatype=DataType.FLOAT_VECTOR, dim=1)
+ self._client.create_collection(
+ collection_name=_META_COLLECTION_NAME,
+ schema=schema,
+ timeout=self._timeout_seconds,
+ )
+ except Exception as exc:
+ logger.warning("Failed to ensure Milvus metadata collection: %s", exc)
+
+ def _load_meta_record(self) -> Dict[str, Any]:
+ self._ensure_meta_collection()
+ try:
+ rows = self._client.get(
+ collection_name=_META_COLLECTION_NAME,
+ ids=[self._physical_collection_name],
+ output_fields=["meta_json", "indexes_json"],
+ timeout=self._timeout_seconds,
+ )
+ except Exception:
+ return {}
+ return dict(rows[0]) if rows else {}
+
+ def _save_meta_record(self, *, meta: Optional[Dict[str, Any]] = None) -> None:
+ self._ensure_meta_collection()
+ existing = self._load_meta_record()
+ meta_json = _json_dumps(meta if meta is not None else self._meta)
+ indexes_json = existing.get("indexes_json") if existing else {}
+ try:
+ self._client.upsert(
+ collection_name=_META_COLLECTION_NAME,
+ data=[
+ {
+ "id": self._physical_collection_name,
+ "meta_json": meta_json,
+ "indexes_json": indexes_json if isinstance(indexes_json, dict) else {},
+ _META_VECTOR_FIELD: [0.0],
+ }
+ ],
+ timeout=self._timeout_seconds,
+ )
+ except Exception as exc:
+ logger.warning("Failed to persist Milvus metadata record: %s", exc)
+
+ def load_remote_meta(self) -> Optional[Dict[str, Any]]:
+ record = self._load_meta_record()
+ raw_meta = record.get("meta_json")
+ if isinstance(raw_meta, str):
+ try:
+ meta = json.loads(raw_meta)
+ except (TypeError, ValueError):
+ meta = None
+ if isinstance(meta, dict):
+ self._meta = meta
+ self._field_types = self._build_field_type_map(meta)
+ self._varchar_lengths = self._build_varchar_length_map()
+ self._vector_dim = self._extract_vector_dim(meta)
+ return meta
+
+ props = self._collection_properties()
+ raw_meta = props.get(_META_PROPERTY_KEY)
+ if isinstance(raw_meta, str):
+ try:
+ meta = json.loads(raw_meta)
+ except (TypeError, ValueError):
+ meta = None
+ if isinstance(meta, dict):
+ self._meta = meta
+ self._field_types = self._build_field_type_map(meta)
+ self._varchar_lengths = self._build_varchar_length_map()
+ self._vector_dim = self._extract_vector_dim(meta)
+ return meta
+
+ return None
+
+ def create_remote_collection(
+ self,
+ meta_data: Dict[str, Any],
+ *,
+ consistency_level: Optional[str] = None,
+ ) -> None:
+ self._meta = dict(meta_data)
+ self._field_types = self._build_field_type_map(self._meta)
+ self._varchar_lengths = self._build_varchar_length_map()
+ self._vector_dim = self._extract_vector_dim(self._meta)
+ if self._vector_dim <= 0:
+ raise ValueError("Milvus collection requires a positive dense vector dimension")
+
+ pymilvus = _import_pymilvus()
+ schema = self._build_schema(pymilvus)
+ create_kwargs: Dict[str, Any] = {}
+ if consistency_level:
+ create_kwargs["consistency_level"] = consistency_level
+ self._client.create_collection(
+ collection_name=self._physical_collection_name,
+ schema=schema,
+ timeout=self._timeout_seconds,
+ **create_kwargs,
+ )
+ self._save_collection_meta()
+
+ def _build_schema(self, pymilvus: Any):
+ DataType = pymilvus.DataType
+ schema = self._client.create_schema(
+ auto_id=False,
+ enable_dynamic_field=True,
+ description=self._meta.get("Description") or "",
+ )
+ seen = set()
+ for field in self._iter_schema_fields():
+ field_name = str(field["FieldName"])
+ if field_name in seen:
+ continue
+ seen.add(field_name)
+ field_type = str(field.get("FieldType") or "").lower()
+ kwargs: Dict[str, Any] = {}
+ if field_name == "id":
+ kwargs.update(is_primary=True, max_length=_ID_MAX_LENGTH)
+ datatype = DataType.VARCHAR
+ elif field_type in _VECTOR_FIELD_TYPES:
+ dim = int(field.get("Dim") or self._vector_dim)
+ if dim <= 0:
+ raise ValueError("Milvus vector field requires Dim")
+ datatype = DataType.FLOAT_VECTOR
+ kwargs["dim"] = dim
+ elif field_name == self._sparse_vector_name or field_type in {"json", "sparse_vector"}:
+ datatype = DataType.JSON
+ kwargs["nullable"] = True
+ elif field_type in _LIST_STRING_FIELD_TYPES:
+ datatype = DataType.ARRAY
+ kwargs.update(
+ element_type=DataType.VARCHAR,
+ max_capacity=1024,
+ max_length=1024,
+ nullable=True,
+ )
+ elif field_type in _INT_FIELD_TYPES:
+ datatype = DataType.INT64
+ kwargs["nullable"] = True
+ elif field_type in _FLOAT_FIELD_TYPES:
+ datatype = DataType.DOUBLE
+ kwargs["nullable"] = True
+ elif field_type in _BOOL_FIELD_TYPES:
+ datatype = DataType.BOOL
+ kwargs["nullable"] = True
+ else:
+ datatype = DataType.VARCHAR
+ kwargs.update(
+ max_length=self._varchar_lengths.get(field_name, _MILVUS_VARCHAR_MAX_LENGTH),
+ nullable=True,
+ )
+ schema.add_field(field_name=field_name, datatype=datatype, **kwargs)
+ return schema
+
+ def _iter_schema_fields(self) -> List[Dict[str, Any]]:
+ fields = [dict(field) for field in self._meta.get("Fields", []) or []]
+ names = {field.get("FieldName") for field in fields}
+ for field_name, field_type in self.INTERNAL_PATH_FIELDS.items():
+ if field_name not in names:
+ fields.append({"FieldName": field_name, "FieldType": field_type})
+ return fields
+
+ def _save_collection_meta(self) -> None:
+ self._save_meta_record(meta=self._meta)
+ try:
+ self._client.alter_collection_properties(
+ collection_name=self._physical_collection_name,
+ properties={_META_PROPERTY_KEY: _json_dumps(self._meta)},
+ timeout=self._timeout_seconds,
+ )
+ except Exception as exc:
+ logger.debug("Milvus collection properties are not available: %s", exc)
+
+ def update(self, fields: Optional[Dict[str, Any]] = None, description: Optional[str] = None):
+ if fields:
+ self._meta.update(fields)
+ if description is not None:
+ self._meta["Description"] = description
+ self._save_collection_meta()
+ return dict(self._meta)
+
+ def get_meta_data(self):
+ if not self._meta:
+ self.load_remote_meta()
+ return dict(self._meta)
+
+ def close(self):
+ return None
+
+ def drop(self):
+ if self.collection_exists():
+ self._client.drop_collection(
+ collection_name=self._physical_collection_name,
+ timeout=self._timeout_seconds,
+ )
+ try:
+ self._client.delete(
+ collection_name=_META_COLLECTION_NAME,
+ ids=[self._physical_collection_name],
+ timeout=self._timeout_seconds,
+ )
+ except Exception:
+ pass
+
+ def create_index(self, index_name: str, meta_data: Dict[str, Any]) -> IIndex:
+ meta = dict(meta_data or {})
+ vector_meta = dict(meta.get("VectorIndex") or {})
+ metric_type = _milvus_metric(vector_meta.get("Distance") or self._distance_metric)
+ existing_indexes = set(self.list_indexes() or [])
+ if index_name in existing_indexes or self._dense_vector_name in existing_indexes:
+ meta["VectorIndex"] = {
+ **vector_meta,
+ "IndexType": "AUTOINDEX",
+ "Distance": self._distance_metric,
+ }
+ self._save_index_meta(index_name, meta)
+ return MilvusIndex(self, index_name, meta)
+
+ index_params = self._client.prepare_index_params()
+ index_params.add_index(
+ field_name=self._dense_vector_name,
+ index_name=index_name,
+ index_type="AUTOINDEX",
+ metric_type=metric_type,
+ )
+ try:
+ self._client.create_index(
+ collection_name=self._physical_collection_name,
+ index_params=index_params,
+ timeout=self._timeout_seconds,
+ )
+ except Exception as exc:
+ if "index already" not in str(exc).lower():
+ raise
+ try:
+ self._client.load_collection(
+ collection_name=self._physical_collection_name,
+ timeout=self._timeout_seconds,
+ )
+ except Exception as exc:
+ logger.debug("Milvus collection load skipped or failed: %s", exc)
+ meta["VectorIndex"] = {
+ **vector_meta,
+ "IndexType": "AUTOINDEX",
+ "Distance": self._distance_metric,
+ }
+ self._save_index_meta(index_name, meta)
+ return MilvusIndex(self, index_name, meta)
+
+ def _save_index_meta(self, index_name: str, meta: Dict[str, Any]) -> None:
+ self._ensure_meta_collection()
+ record = self._load_meta_record()
+ indexes = record.get("indexes_json") if record else {}
+ if not isinstance(indexes, dict):
+ indexes = {}
+ indexes[index_name] = meta
+ try:
+ self._client.upsert(
+ collection_name=_META_COLLECTION_NAME,
+ data=[
+ {
+ "id": self._physical_collection_name,
+ "meta_json": record.get("meta_json") or _json_dumps(self._meta),
+ "indexes_json": indexes,
+ _META_VECTOR_FIELD: [0.0],
+ }
+ ],
+ timeout=self._timeout_seconds,
+ )
+ except Exception as exc:
+ logger.warning("Failed to persist Milvus index metadata: %s", exc)
+ try:
+ self._client.alter_collection_properties(
+ collection_name=self._physical_collection_name,
+ properties={f"{_INDEX_META_PROPERTY_PREFIX}{index_name}": _json_dumps(meta)},
+ timeout=self._timeout_seconds,
+ )
+ except Exception as exc:
+ logger.debug("Milvus index properties are not available: %s", exc)
+
+ def has_index(self, index_name: str) -> bool:
+ return self.get_index_meta_data(index_name) is not None or index_name in (
+ self.list_indexes() or []
+ )
+
+ def get_index(self, index_name: str) -> Optional[IIndex]:
+ meta = self.get_index_meta_data(index_name)
+ return MilvusIndex(self, index_name, meta) if meta else None
+
+ def update_index(
+ self,
+ index_name: str,
+ scalar_index: Optional[Dict[str, Any]] = None,
+ description: Optional[str] = None,
+ ):
+ meta = self.get_index_meta_data(index_name) or {"IndexName": index_name}
+ if scalar_index is not None:
+ meta["ScalarIndex"] = (
+ list(scalar_index.keys()) if isinstance(scalar_index, dict) else list(scalar_index)
+ )
+ if description is not None:
+ meta["Description"] = description
+ self._save_index_meta(index_name, meta)
+ return meta
+
+ def get_index_meta_data(self, index_name: str):
+ record = self._load_meta_record()
+ indexes = record.get("indexes_json") if record else {}
+ if isinstance(indexes, dict) and isinstance(indexes.get(index_name), dict):
+ return indexes[index_name]
+
+ props = self._collection_properties()
+ raw_meta = props.get(f"{_INDEX_META_PROPERTY_PREFIX}{index_name}")
+ if not isinstance(raw_meta, str):
+ return None
+ try:
+ meta = json.loads(raw_meta)
+ except (TypeError, ValueError):
+ return None
+ return meta if isinstance(meta, dict) else None
+
+ def list_indexes(self):
+ try:
+ return list(
+ self._client.list_indexes(
+ collection_name=self._physical_collection_name,
+ timeout=self._timeout_seconds,
+ )
+ or []
+ )
+ except Exception:
+ return []
+
+ def drop_index(self, index_name: str):
+ try:
+ self._client.release_collection(
+ collection_name=self._physical_collection_name,
+ timeout=self._timeout_seconds,
+ )
+ except Exception:
+ pass
+ for remote_name in list(self.list_indexes() or []):
+ if remote_name == index_name or str(remote_name).startswith(f"{index_name}_"):
+ try:
+ self._client.drop_index(
+ collection_name=self._physical_collection_name,
+ index_name=remote_name,
+ timeout=self._timeout_seconds,
+ )
+ except Exception as exc:
+ logger.warning("Failed to drop Milvus index %s: %s", remote_name, exc)
+ try:
+ self._client.drop_collection_properties(
+ collection_name=self._physical_collection_name,
+ property_keys=[f"{_INDEX_META_PROPERTY_PREFIX}{index_name}"],
+ timeout=self._timeout_seconds,
+ )
+ except Exception:
+ pass
+ try:
+ record = self._load_meta_record()
+ indexes = record.get("indexes_json") if record else {}
+ if isinstance(indexes, dict) and index_name in indexes:
+ indexes.pop(index_name, None)
+ self._client.upsert(
+ collection_name=_META_COLLECTION_NAME,
+ data=[
+ {
+ "id": self._physical_collection_name,
+ "meta_json": record.get("meta_json") or _json_dumps(self._meta),
+ "indexes_json": indexes,
+ _META_VECTOR_FIELD: [0.0],
+ }
+ ],
+ timeout=self._timeout_seconds,
+ )
+ except Exception:
+ pass
+
+ def _prepare_record_for_write(self, record: Dict[str, Any]) -> Dict[str, Any]:
+ prepared: Dict[str, Any] = {}
+ for field_name, value in record.items():
+ if value is None:
+ continue
+ field_type = self._field_types.get(field_name, "")
+ if field_name == "id":
+ text = str(value)
+ if len(text.encode("utf-8")) > _ID_MAX_LENGTH:
+ raise ValueError("Milvus record id exceeds 512 bytes")
+ prepared[field_name] = text
+ elif field_name == self._dense_vector_name:
+ prepared[field_name] = self._coerce_dense_vector(value)
+ elif field_name == "scope_roots":
+ prepared[field_name] = _encode_scope_roots(value)
+ elif field_name == self._sparse_vector_name or field_type == "sparse_vector":
+ prepared[field_name] = self._coerce_sparse_vector(value)
+ elif field_type in _LIST_STRING_FIELD_TYPES:
+ prepared[field_name] = [str(item) for item in (value or []) if item is not None]
+ elif field_type in _INT_FIELD_TYPES:
+ prepared[field_name] = int(value)
+ elif field_type in _FLOAT_FIELD_TYPES:
+ number = float(value)
+ prepared[field_name] = number if math.isfinite(number) else 0.0
+ elif field_type in _BOOL_FIELD_TYPES:
+ prepared[field_name] = bool(value)
+ elif field_type == "date_time":
+ prepared[field_name] = str(_coerce_datetime_value(value))
+ elif isinstance(value, str):
+ limit = self._varchar_lengths.get(field_name)
+ prepared[field_name] = _truncate_utf8(value, limit) if limit else value
+ else:
+ prepared[field_name] = value
+ return prepared
+
+ def _coerce_dense_vector(self, value: Any) -> List[float]:
+ if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
+ raise ValueError("Milvus dense vector must be a sequence of floats")
+ vector = []
+ for item in value:
+ number = float(item)
+ vector.append(number if math.isfinite(number) else 0.0)
+ if self._vector_dim > 0 and len(vector) != self._vector_dim:
+ raise ValueError(
+ f"Milvus dense vector dimension mismatch: expected {self._vector_dim}, "
+ f"got {len(vector)}"
+ )
+ return vector
+
+ @staticmethod
+ def _coerce_sparse_vector(value: Any) -> Dict[str, float]:
+ if value in (None, ""):
+ return {}
+ if isinstance(value, str):
+ decoded = _json_loads(value)
+ value = decoded if isinstance(decoded, dict) else {}
+ if not isinstance(value, dict):
+ return {}
+ result: Dict[str, float] = {}
+ for key, raw_value in value.items():
+ try:
+ number = float(raw_value)
+ except (TypeError, ValueError):
+ continue
+ if math.isfinite(number):
+ result[str(key)] = number
+ return result
+
+ def _record_from_entity(self, entity: Dict[str, Any]) -> tuple[Any, Dict[str, Any]]:
+ record = dict(entity or {})
+ record_id = record.pop("id", None)
+ if record_id is None:
+ record_id = entity.get("pk") or entity.get("primary_key")
+ return record_id, self._decode_record(record)
+
+ def _decode_record(self, record: Dict[str, Any]) -> Dict[str, Any]:
+ decoded = dict(record)
+ sparse = decoded.get(self._sparse_vector_name)
+ if isinstance(sparse, str):
+ parsed = _json_loads(sparse)
+ decoded[self._sparse_vector_name] = parsed if isinstance(parsed, dict) else sparse
+ return decoded
+
+ def _select_output_fields(
+ self,
+ output_fields: Optional[List[str]],
+ *,
+ include_vector: bool = False,
+ include_sparse: bool = False,
+ ) -> List[str]:
+ if output_fields:
+ fields = [field for field in output_fields if field != "id"]
+ else:
+ fields = [field for field in self._field_types if field != "id"]
+ if not include_vector:
+ fields = [field for field in fields if field != self._dense_vector_name]
+ if not include_sparse:
+ fields = [field for field in fields if field != self._sparse_vector_name]
+ return list(dict.fromkeys(fields))
+
+ def search_by_vector(
+ self,
+ index_name: str,
+ dense_vector: Optional[List[float]] = None,
+ limit: int = 10,
+ offset: int = 0,
+ filters: Optional[str] = None,
+ sparse_vector: Optional[Dict[str, float]] = None,
+ output_fields: Optional[List[str]] = None,
+ ) -> SearchResult:
+ del index_name
+ if limit <= 0:
+ return SearchResult()
+ if dense_vector is None:
+ return self._search_by_sparse(sparse_vector, limit, offset, filters, output_fields)
+
+ fetch_limit = max(limit + offset, limit)
+ fields = self._select_output_fields(
+ output_fields,
+ include_vector=False,
+ include_sparse=bool(sparse_vector),
+ )
+ raw_results = self._client.search(
+ collection_name=self._physical_collection_name,
+ data=[self._coerce_dense_vector(dense_vector)],
+ anns_field=self._dense_vector_name,
+ filter=filters or "",
+ limit=fetch_limit,
+ output_fields=fields,
+ search_params={"metric_type": _milvus_metric(self._distance_metric)},
+ timeout=self._timeout_seconds,
+ )
+ hits = raw_results[0] if raw_results else []
+ items: List[SearchItemResult] = []
+ for hit in hits:
+ entity = hit.get("entity") if isinstance(hit, dict) else None
+ entity = dict(entity or {})
+ if "id" not in entity and isinstance(hit, dict):
+ entity["id"] = hit.get("id")
+ record_id, payload = self._record_from_entity(entity)
+ score = _score_from_hit(hit, self._distance_metric) if isinstance(hit, dict) else 0.0
+ if sparse_vector:
+ sparse_payload = payload.pop(self._sparse_vector_name, None)
+ score += _sparse_dot(
+ sparse_vector, sparse_payload if isinstance(sparse_payload, dict) else None
+ )
+ items.append(SearchItemResult(id=record_id, fields=payload, score=score))
+ if sparse_vector:
+ items.sort(key=lambda item: item.score or 0.0, reverse=True)
+ return SearchResult(data=items[offset : offset + limit])
+
+ def _search_by_sparse(
+ self,
+ sparse_vector: Optional[Dict[str, float]],
+ limit: int,
+ offset: int,
+ filters: Optional[str],
+ output_fields: Optional[List[str]],
+ ) -> SearchResult:
+ if not sparse_vector:
+ return SearchResult()
+ fields = self._select_output_fields(output_fields, include_sparse=True)
+ rows = self._client.query(
+ collection_name=self._physical_collection_name,
+ filter=filters or "",
+ output_fields=fields,
+ limit=max(limit + offset, _DEFAULT_QUERY_LIMIT),
+ timeout=self._timeout_seconds,
+ )
+ items = []
+ for row in rows:
+ record_id, payload = self._record_from_entity(row)
+ sparse_payload = payload.pop(self._sparse_vector_name, None)
+ score = _sparse_dot(
+ sparse_vector, sparse_payload if isinstance(sparse_payload, dict) else None
+ )
+ if score > 0:
+ items.append(SearchItemResult(id=record_id, fields=payload, score=score))
+ items.sort(key=lambda item: item.score or 0.0, reverse=True)
+ return SearchResult(data=items[offset : offset + limit])
+
+ def search_by_keywords(
+ self,
+ index_name: str,
+ keywords: Optional[List[str]] = None,
+ query: Optional[str] = None,
+ limit: int = 10,
+ offset: int = 0,
+ filters: Optional[str] = None,
+ output_fields: Optional[List[str]] = None,
+ ) -> SearchResult:
+ del index_name
+ query_text = query or " ".join(keywords or [])
+ if not query_text.strip():
+ return SearchResult()
+ compiler = MilvusFilterCompiler(self._field_types)
+ text_filter = compiler.compile_legacy_filter(
+ {
+ "op": "or",
+ "conds": [
+ {"op": "contains", "field": field, "substring": query_text}
+ for field in ("name", "description", "abstract", "tags", "content")
+ if field in self._field_types
+ ],
+ }
+ )
+ combined = (
+ f"({filters}) and ({text_filter})"
+ if filters and text_filter
+ else filters or text_filter
+ )
+ return self.search_by_random("", limit, offset, combined, output_fields)
+
+ def search_by_id(
+ self,
+ index_name: str,
+ id: Any,
+ limit: int = 10,
+ offset: int = 0,
+ filters: Optional[str] = None,
+ output_fields: Optional[List[str]] = None,
+ ) -> SearchResult:
+ rows = self._client.get(
+ collection_name=self._physical_collection_name,
+ ids=[str(id)],
+ output_fields=self._select_output_fields(
+ None,
+ include_vector=True,
+ include_sparse=True,
+ ),
+ timeout=self._timeout_seconds,
+ )
+ if not rows:
+ return SearchResult()
+ dense_vector = rows[0].get(self._dense_vector_name)
+ sparse_vector = rows[0].get(self._sparse_vector_name)
+ result = self.search_by_vector(
+ index_name=index_name,
+ dense_vector=dense_vector,
+ sparse_vector=sparse_vector if isinstance(sparse_vector, dict) else None,
+ limit=limit + offset + 1,
+ offset=0,
+ filters=filters,
+ output_fields=output_fields,
+ )
+ data = [item for item in result.data if str(item.id) != str(id)]
+ return SearchResult(data=data[offset : offset + limit])
+
+ def search_by_multimodal(
+ self,
+ index_name: str,
+ text: Optional[str],
+ image: Optional[Any],
+ video: Optional[Any],
+ limit: int = 10,
+ offset: int = 0,
+ filters: Optional[str] = None,
+ output_fields: Optional[List[str]] = None,
+ ) -> SearchResult:
+ raise NotImplementedError("MilvusCollection.search_by_multimodal is not supported")
+
+ def search_by_random(
+ self,
+ index_name: str,
+ limit: int = 10,
+ offset: int = 0,
+ filters: Optional[str] = None,
+ output_fields: Optional[List[str]] = None,
+ ) -> SearchResult:
+ del index_name
+ rows = self._client.query(
+ collection_name=self._physical_collection_name,
+ filter=filters or "",
+ output_fields=self._select_output_fields(output_fields),
+ limit=limit,
+ offset=offset,
+ timeout=self._timeout_seconds,
+ )
+ items = []
+ for row in rows:
+ record_id, payload = self._record_from_entity(row)
+ items.append(SearchItemResult(id=record_id, fields=payload, score=1.0))
+ return SearchResult(data=items)
+
+ def search_by_scalar(
+ self,
+ index_name: str,
+ field: str,
+ order: Optional[str] = "desc",
+ limit: int = 10,
+ offset: int = 0,
+ filters: Optional[str] = None,
+ output_fields: Optional[List[str]] = None,
+ ) -> SearchResult:
+ del index_name
+ fields = self._select_output_fields(output_fields)
+ if field not in fields:
+ fields.append(field)
+ rows = self._client.query(
+ collection_name=self._physical_collection_name,
+ filter=filters or "",
+ output_fields=fields,
+ limit=max(limit + offset, _DEFAULT_QUERY_LIMIT),
+ timeout=self._timeout_seconds,
+ )
+ reverse = (order or "desc").lower() == "desc"
+ rows.sort(key=lambda row: (row.get(field) is None, row.get(field)), reverse=reverse)
+ items = []
+ for row in rows[offset : offset + limit]:
+ record_id, payload = self._record_from_entity(row)
+ score = (
+ payload.pop(field, None)
+ if output_fields and field not in output_fields
+ else payload.get(field)
+ )
+ items.append(
+ SearchItemResult(
+ id=record_id,
+ fields=payload,
+ score=score if isinstance(score, (int, float)) else None,
+ )
+ )
+ return SearchResult(data=items)
+
+ def upsert_data(self, data_list: List[Dict[str, Any]], ttl=0):
+ del ttl
+ if not data_list:
+ return []
+ records = [self._prepare_record_for_write(record) for record in data_list]
+ self._client.upsert(
+ collection_name=self._physical_collection_name,
+ data=records,
+ timeout=self._timeout_seconds,
+ )
+ return [record.get("id") for record in records if record.get("id") is not None]
+
+ def update_data(self, data_list: List[Dict[str, Any]]):
+ updated_records: List[Dict[str, Any]] = []
+ updated_ids: List[Any] = []
+ for raw_data in data_list:
+ if "id" not in raw_data or raw_data.get("id") in (None, ""):
+ raise ValueError("Milvus update requires id")
+ record_id = str(raw_data["id"])
+ existing = self.fetch_data([record_id]).items
+ if not existing:
+ raise ValueError(f"Milvus entity does not exist for update: {record_id}")
+ merged = dict(existing[0].fields or {})
+ merged["id"] = existing[0].id
+ merged.update(raw_data)
+ updated_records.append(self._prepare_record_for_write(merged))
+ updated_ids.append(record_id)
+ if updated_records:
+ self._client.upsert(
+ collection_name=self._physical_collection_name,
+ data=updated_records,
+ timeout=self._timeout_seconds,
+ )
+ return updated_ids
+
+ def fetch_data(self, primary_keys: List[Any]):
+ if not primary_keys:
+ return FetchDataInCollectionResult()
+ rows = self._client.get(
+ collection_name=self._physical_collection_name,
+ ids=[str(pk) for pk in primary_keys],
+ output_fields=self._select_output_fields(
+ None,
+ include_vector=True,
+ include_sparse=True,
+ ),
+ timeout=self._timeout_seconds,
+ )
+ items = []
+ found_ids = set()
+ for row in rows:
+ record_id, payload = self._record_from_entity(row)
+ if record_id is not None:
+ found_ids.add(str(record_id))
+ items.append(DataItem(id=record_id, fields=payload))
+ return FetchDataInCollectionResult(
+ items=items,
+ ids_not_exist=[pk for pk in primary_keys if str(pk) not in found_ids],
+ )
+
+ def delete_data(self, primary_keys: List[Any]):
+ if not primary_keys:
+ return None
+ self._client.delete(
+ collection_name=self._physical_collection_name,
+ ids=[str(pk) for pk in primary_keys],
+ timeout=self._timeout_seconds,
+ )
+ return None
+
+ def delete_all_data(self):
+ self._client.delete(
+ collection_name=self._physical_collection_name,
+ filter='id != ""',
+ timeout=self._timeout_seconds,
+ )
+
+ def aggregate_data(
+ self,
+ index_name: str,
+ op: str = "count",
+ field: Optional[str] = None,
+ filters: Optional[str] = None,
+ cond: Optional[Dict[str, Any]] = None,
+ ) -> AggregateResult:
+ del index_name
+ if op != "count":
+ return AggregateResult(agg={}, op=op, field=field)
+ if not field:
+ try:
+ rows = self._client.query(
+ collection_name=self._physical_collection_name,
+ filter=filters or "",
+ output_fields=["count(*)"],
+ timeout=self._timeout_seconds,
+ )
+ total = int((rows[0] if rows else {}).get("count(*)", 0))
+ except Exception:
+ rows = self._client.query(
+ collection_name=self._physical_collection_name,
+ filter=filters or "",
+ output_fields=["id"],
+ limit=_DEFAULT_QUERY_LIMIT,
+ timeout=self._timeout_seconds,
+ )
+ total = len(rows)
+ return AggregateResult(agg={"_total": total}, op=op, field=None)
+
+ rows = self._client.query(
+ collection_name=self._physical_collection_name,
+ filter=filters or "",
+ output_fields=[field],
+ limit=_DEFAULT_QUERY_LIMIT,
+ timeout=self._timeout_seconds,
+ )
+ grouped: Dict[Any, int] = {}
+ for row in rows:
+ value = row.get(field)
+ if value is not None:
+ grouped[value] = grouped.get(value, 0) + 1
+ if cond:
+ grouped = {
+ key: value
+ for key, value in grouped.items()
+ if (cond.get("gt") is None or value > cond["gt"])
+ and (cond.get("gte") is None or value >= cond["gte"])
+ and (cond.get("lt") is None or value < cond["lt"])
+ and (cond.get("lte") is None or value <= cond["lte"])
+ }
+ return AggregateResult(agg=grouped, op=op, field=field)
+
+
+class MilvusFilterCompiler:
+ """Compile OpenViking filters to safe Milvus boolean expressions."""
+
+ def __init__(self, field_types: Optional[Dict[str, str]] = None) -> None:
+ self._field_types = field_types or {}
+
+ def compile(self, expr: FilterExpr | Dict[str, Any] | str | None) -> str:
+ if expr is None:
+ return ""
+ if isinstance(expr, str):
+ return expr.strip()
+ if isinstance(expr, dict):
+ if "op" in expr:
+ return self.compile_legacy_filter(expr)
+ return self._compile_mapping(expr)
+ if isinstance(expr, RawDSL):
+ payload = expr.payload
+ if isinstance(payload, dict) and "expr" in payload:
+ return str(payload["expr"]).strip()
+ return self.compile(payload)
+ if isinstance(expr, And):
+ return self._join("and", [self.compile(cond) for cond in expr.conds if cond])
+ if isinstance(expr, Or):
+ return self._join("or", [self.compile(cond) for cond in expr.conds if cond])
+ if isinstance(expr, Eq):
+ return self._eq(expr.field, expr.value)
+ if isinstance(expr, In):
+ return self._in(expr.field, list(expr.values))
+ if isinstance(expr, Range):
+ return self._range(
+ expr.field,
+ gte=expr.gte,
+ gt=expr.gt,
+ lte=expr.lte,
+ lt=expr.lt,
+ )
+ if isinstance(expr, TimeRange):
+ return self._range(
+ expr.field,
+ gte=_coerce_datetime_value(expr.start),
+ lt=_coerce_datetime_value(expr.end),
+ )
+ if isinstance(expr, Contains):
+ return self._contains(expr.field, expr.substring)
+ if isinstance(expr, PathScope):
+ path = MilvusCollectionAdapter._normalize_path(
+ CollectionAdapter._encode_uri_field_value(expr.path)
+ if expr.field in CollectionAdapter._URI_FIELD_NAMES
+ else expr.path
+ )
+ if expr.depth == 0:
+ return self._eq(expr.field, path)
+ if expr.depth == 1:
+ return self._eq("parent_uri", path)
+ if expr.depth == -1:
+ return self._contains("scope_roots", f"\n{path}\n")
+ raise ValueError(
+ f"Milvus adapter only supports PathScope depth 0/1/-1, got {expr.depth}"
+ )
+ raise TypeError(f"Unsupported filter expr type: {type(expr)!r}")
+
+ def compile_legacy_filter(self, payload: Dict[str, Any]) -> str:
+ op = str(payload.get("op") or "").lower()
+ if not op:
+ return self._compile_mapping(payload)
+ if op in {"and", "or"}:
+ return self._join(
+ op,
+ [self.compile_legacy_filter(cond) for cond in payload.get("conds", []) if cond],
+ )
+ if op == "must":
+ field = payload.get("field")
+ values = payload.get("conds", []) or []
+ if not values:
+ return ""
+ if field in CollectionAdapter._URI_FIELD_NAMES:
+ values = [
+ MilvusCollectionAdapter._normalize_path(
+ CollectionAdapter._encode_uri_field_value(value)
+ )
+ for value in values
+ ]
+ return (
+ self._in(str(field), list(values))
+ if len(values) > 1
+ else self._eq(field, values[0])
+ )
+ if op == "must_not":
+ field = payload.get("field")
+ values = payload.get("conds", []) or []
+ if not values:
+ return ""
+ expr = (
+ self._in(str(field), list(values))
+ if len(values) > 1
+ else self._eq(field, values[0])
+ )
+ return f"not ({expr})" if expr else ""
+ if op in {"range", "time_range"}:
+ return self._range(
+ str(payload.get("field")),
+ gte=payload.get("gte"),
+ gt=payload.get("gt"),
+ lte=payload.get("lte"),
+ lt=payload.get("lt"),
+ )
+ if op == "range_out":
+ field = str(payload.get("field"))
+ branches = []
+ if payload.get("gte") is not None:
+ branches.append(self._range(field, lt=payload["gte"]))
+ if payload.get("lte") is not None:
+ branches.append(self._range(field, gt=payload["lte"]))
+ return self._join("or", branches)
+ if op == "contains":
+ return self._contains(str(payload.get("field")), str(payload.get("substring", "")))
+ if op == "prefix":
+ field = str(payload.get("field"))
+ prefix = str(payload.get("prefix", ""))
+ if field in CollectionAdapter._URI_FIELD_NAMES:
+ return self.compile(PathScope(field, prefix, depth=-1))
+ return self._like(field, f"{prefix}%")
+ return self._compile_mapping(payload)
+
+ def _compile_mapping(self, payload: Dict[str, Any]) -> str:
+ return self._join("and", [self._eq(str(key), value) for key, value in payload.items()])
+
+ @staticmethod
+ def _join(op: str, exprs: Iterable[str]) -> str:
+ items = [expr for expr in exprs if expr]
+ if not items:
+ return ""
+ if len(items) == 1:
+ return items[0]
+ return f" {op} ".join(f"({item})" for item in items)
+
+ def _validate_field(self, field: Any) -> str:
+ if not isinstance(field, str) or not _FIELD_NAME_RE.match(field):
+ raise ValueError(f"Invalid Milvus filter field: {field!r}")
+ return field
+
+ def _eq(self, field: Any, value: Any) -> str:
+ field_name = self._validate_field(field)
+ field_type = self._field_types.get(field_name, "")
+ if value is None:
+ return f"{field_name} is null"
+ if field_type in _LIST_STRING_FIELD_TYPES:
+ return f"ARRAY_CONTAINS({field_name}, {_quote_value(value)})"
+ return f"{field_name} == {_quote_value(value)}"
+
+ def _in(self, field: str, values: List[Any]) -> str:
+ field_name = self._validate_field(field)
+ if not values:
+ return ""
+ field_type = self._field_types.get(field_name, "")
+ if field_type in _LIST_STRING_FIELD_TYPES:
+ return self._join(
+ "or",
+ [f"ARRAY_CONTAINS({field_name}, {_quote_value(value)})" for value in values],
+ )
+ non_null = [value for value in values if value is not None]
+ expr = f"{field_name} in {_format_value_list(non_null)}" if non_null else ""
+ if any(value is None for value in values):
+ null_expr = f"{field_name} is null"
+ return self._join("or", [expr, null_expr])
+ return expr
+
+ def _range(self, field: str, **bounds: Any) -> str:
+ field_name = self._validate_field(field)
+ parts = []
+ operators = {"gte": ">=", "gt": ">", "lte": "<=", "lt": "<"}
+ for key, operator in operators.items():
+ value = bounds.get(key)
+ if value is not None:
+ parts.append(f"{field_name} {operator} {_quote_value(value)}")
+ return " and ".join(parts)
+
+ def _contains(self, field: str, substring: str) -> str:
+ field_name = self._validate_field(field)
+ field_type = self._field_types.get(field_name, "")
+ if field_type in _LIST_STRING_FIELD_TYPES:
+ return f"ARRAY_CONTAINS({field_name}, {_quote_value(substring)})"
+ return self._like(field_name, f"%{substring}%")
+
+ def _like(self, field: str, pattern: str) -> str:
+ field_name = self._validate_field(field)
+ return f"{field_name} like {_quote_value(pattern)}"
+
+
+class MilvusCollectionAdapter(CollectionAdapter):
+ """CollectionAdapter backed by Milvus or Zilliz Cloud."""
+
+ mode = "milvus"
+ INTERNAL_PATH_FIELDS = ["parent_uri", "scope_roots", "uri_depth"]
+
+ def __init__(
+ self,
+ *,
+ uri: str,
+ token: Optional[str],
+ db_name: Optional[str],
+ consistency_level: Optional[str],
+ timeout_seconds: int,
+ project_name: str,
+ collection_name: str,
+ index_name: str,
+ distance_metric: str,
+ dense_vector_name: str,
+ sparse_vector_name: str,
+ ) -> None:
+ super().__init__(collection_name=collection_name, index_name=index_name)
+ self._uri = uri
+ self._token = token
+ self._db_name = db_name
+ self._consistency_level = consistency_level
+ self._timeout_seconds = int(timeout_seconds)
+ self._project_name = project_name
+ self._distance_metric = _normalize_distance(distance_metric)
+ self._dense_vector_name = dense_vector_name
+ self._sparse_vector_name = sparse_vector_name
+ self._client = None
+
+ @classmethod
+ def from_config(cls, config: Any):
+ cfg = getattr(config, "milvus", None)
+ params = dict(getattr(config, "custom_params", {}) or {})
+ uri = getattr(cfg, "uri", None) or getattr(config, "url", None) or params.get("uri")
+ token = getattr(cfg, "token", None) or params.get("token")
+ db_name = getattr(cfg, "db_name", None) or params.get("db_name")
+ consistency_level = getattr(cfg, "consistency_level", None) or params.get(
+ "consistency_level"
+ )
+ return cls(
+ uri=str(uri or _DEFAULT_URI),
+ token=str(token) if token else None,
+ db_name=str(db_name) if db_name else None,
+ consistency_level=str(consistency_level) if consistency_level else None,
+ timeout_seconds=int(
+ getattr(cfg, "timeout_seconds", None)
+ or params.get("timeout_seconds")
+ or _DEFAULT_TIMEOUT_SECONDS
+ ),
+ project_name=config.project_name or "default",
+ collection_name=config.name or "context",
+ index_name=config.index_name or "default",
+ distance_metric=config.distance_metric or "cosine",
+ dense_vector_name=str(
+ getattr(cfg, "dense_vector_name", None)
+ or params.get("dense_vector_name")
+ or "vector"
+ ),
+ sparse_vector_name=str(
+ getattr(cfg, "sparse_vector_name", None)
+ or params.get("sparse_vector_name")
+ or "sparse_vector"
+ ),
+ )
+
+ @property
+ def physical_collection_name(self) -> str:
+ return _safe_collection_name(self._project_name, self._collection_name)
+
+ def _connect(self):
+ if self._client is not None:
+ return self._client
+ pymilvus = _import_pymilvus()
+ kwargs: Dict[str, Any] = {
+ "uri": self._uri,
+ "timeout": self._timeout_seconds,
+ }
+ if self._token:
+ kwargs["token"] = self._token
+ if self._db_name:
+ kwargs["db_name"] = self._db_name
+ self._client = pymilvus.MilvusClient(**kwargs)
+ return self._client
+
+ def _new_collection(self, meta: Optional[Dict[str, Any]] = None) -> MilvusCollection:
+ return MilvusCollection(
+ client=self._connect(),
+ logical_collection_name=self._collection_name,
+ physical_collection_name=self.physical_collection_name,
+ project_name=self._project_name,
+ dense_vector_name=self._dense_vector_name,
+ sparse_vector_name=self._sparse_vector_name,
+ distance_metric=self._distance_metric,
+ timeout_seconds=self._timeout_seconds,
+ meta=meta,
+ )
+
+ def _load_existing_collection_if_needed(self) -> None:
+ if self._collection is not None:
+ return
+ raw_collection = self._new_collection()
+ if not raw_collection.collection_exists():
+ return
+ meta = raw_collection.load_remote_meta()
+ if not meta:
+ raise RuntimeError(
+ "Milvus collection exists but OpenViking metadata is missing: "
+ f"{self.physical_collection_name}. Use a different project/name, restore metadata, "
+ "or drop the stale Milvus collection."
+ )
+ self._collection = Collection(raw_collection)
+
+ def _create_backend_collection(self, meta: Dict[str, Any]) -> Collection:
+ raw_collection = self._new_collection(meta)
+ raw_collection.create_remote_collection(meta, consistency_level=self._consistency_level)
+ return Collection(raw_collection)
+
+ def close(self) -> None:
+ super().close()
+ if self._client is not None:
+ close = getattr(self._client, "close", None)
+ if callable(close):
+ close()
+ self._client = None
+
+ def _sanitize_scalar_index_fields(
+ self,
+ scalar_index_fields: list[str],
+ fields_meta: list[dict[str, Any]],
+ ) -> list[str]:
+ del fields_meta
+ return list(dict.fromkeys(list(scalar_index_fields) + self.INTERNAL_PATH_FIELDS))
+
+ def _build_default_index_meta(
+ self,
+ *,
+ index_name: str,
+ distance: str,
+ use_sparse: bool,
+ sparse_weight: float,
+ scalar_index_fields: list[str],
+ ) -> Dict[str, Any]:
+ if use_sparse:
+ logger.warning(
+ "Milvus adapter stores sparse vectors but currently searches dense vectors first; "
+ "sparse scores are only applied to returned dense candidates."
+ )
+ return {
+ "IndexName": index_name,
+ "VectorIndex": {
+ "IndexType": "AUTOINDEX",
+ "Distance": _normalize_distance(distance),
+ "Quant": "int8",
+ "EnableSparse": bool(use_sparse),
+ "SearchWithSparseLogitAlpha": sparse_weight,
+ },
+ "ScalarIndex": scalar_index_fields,
+ }
+
+ @staticmethod
+ def _normalize_path(path: str) -> str:
+ stripped = (path or "").strip()
+ if not stripped:
+ return "/"
+ if not stripped.startswith("/"):
+ stripped = f"/{stripped}"
+ if len(stripped) > 1:
+ stripped = stripped.rstrip("/")
+ return stripped or "/"
+
+ @classmethod
+ def _compute_parent_uri(cls, uri: str) -> str:
+ normalized = cls._normalize_path(uri)
+ if normalized == "/":
+ return "/"
+ parts = normalized.strip("/").split("/")
+ if len(parts) <= 1:
+ return "/"
+ return "/" + "/".join(parts[:-1])
+
+ @classmethod
+ def _compute_scope_roots(cls, uri: str) -> List[str]:
+ normalized = cls._normalize_path(uri)
+ if normalized == "/":
+ return ["/"]
+ parts = normalized.strip("/").split("/")
+ roots = ["/"]
+ current_parts: List[str] = []
+ for part in parts[:-1]:
+ current_parts.append(part)
+ roots.append("/" + "/".join(current_parts))
+ return roots
+
+ def _normalize_record_for_write(self, record: Dict[str, Any]) -> Dict[str, Any]:
+ normalized = dict(super()._normalize_record_for_write(record))
+ raw_uri = normalized.get("uri")
+ if isinstance(raw_uri, str):
+ normalized_uri = self._normalize_path(raw_uri)
+ normalized["uri"] = normalized_uri
+ normalized["parent_uri"] = self._compute_parent_uri(normalized_uri)
+ normalized["scope_roots"] = self._compute_scope_roots(normalized_uri)
+ normalized["uri_depth"] = len(
+ [part for part in normalized_uri.strip("/").split("/") if part]
+ )
+ return normalized
+
+ def _normalize_record_for_read(self, record: Dict[str, Any]) -> Dict[str, Any]:
+ normalized = super()._normalize_record_for_read(record)
+ for field_name in self.INTERNAL_PATH_FIELDS:
+ normalized.pop(field_name, None)
+ return normalized
+
+ def _field_types_for_filter(self) -> Dict[str, str]:
+ try:
+ collection = self.get_collection()
+ meta = collection.get_meta_data() or {}
+ field_types = MilvusCollection._build_field_type_map(meta)
+ except Exception:
+ field_types = {
+ "id": "string",
+ self._dense_vector_name: "vector",
+ self._sparse_vector_name: "sparse_vector",
+ "uri": "path",
+ "parent_uri": "path",
+ "scope_roots": "string",
+ "uri_depth": "int64",
+ "level": "int64",
+ "active_count": "int64",
+ "search_tags": "list",
+ }
+ return field_types
+
+ def _compile_filter(self, expr: FilterExpr | Dict[str, Any] | str | None) -> str:
+ return MilvusFilterCompiler(self._field_types_for_filter()).compile(expr)
+
+ def update_data(self, data_list: List[Dict[str, Any]]):
+ collection = self.get_collection()
+ normalized = [self._normalize_record_for_write(item) for item in data_list]
+ result = collection.update_data(normalized)
+ return [str(item) for item in (result or []) if item is not None]
diff --git a/openviking_cli/utils/config/vectordb_config.py b/openviking_cli/utils/config/vectordb_config.py
index 36d97e4417..cee25b12b0 100644
--- a/openviking_cli/utils/config/vectordb_config.py
+++ b/openviking_cli/utils/config/vectordb_config.py
@@ -151,6 +151,59 @@ class CuVSConfig(BaseModel):
model_config = {"extra": "forbid"}
+class MilvusConfig(BaseModel):
+ """Configuration for Milvus backend."""
+
+ uri: str = Field(
+ default="./milvus.db",
+ description=(
+ "Milvus URI. Use a local .db path for Milvus Lite, "
+ "'http://localhost:19530' for self-hosted Milvus, or a Zilliz Cloud endpoint."
+ ),
+ )
+ token: Optional[str] = Field(
+ default=None,
+ description="Optional token for authenticated Milvus or Zilliz Cloud deployments.",
+ )
+ db_name: Optional[str] = Field(
+ default=None,
+ description="Optional Milvus database name for server or cloud deployments.",
+ )
+ consistency_level: Optional[str] = Field(
+ default=None,
+ description="Optional Milvus consistency level: Strong, Session, Bounded, or Eventually.",
+ )
+ timeout_seconds: int = Field(default=30, description="Milvus client timeout in seconds")
+ dense_vector_name: str = Field(default="vector", description="Dense vector field name")
+ sparse_vector_name: str = Field(
+ default="sparse_vector", description="Sparse vector JSON field name"
+ )
+
+ model_config = {"extra": "forbid"}
+
+ @model_validator(mode="after")
+ def validate_milvus(self):
+ self.uri = (self.uri or "./milvus.db").strip()
+ if not self.uri:
+ raise ValueError("Milvus uri must not be empty")
+ if self.uri.startswith(("http://", "https://")):
+ self.uri = self.uri.rstrip("/")
+ if self.consistency_level:
+ allowed = {"Strong", "Session", "Bounded", "Eventually"}
+ normalized = self.consistency_level.strip()
+ title_value = normalized[:1].upper() + normalized[1:].lower()
+ if title_value not in allowed:
+ raise ValueError(
+ "Milvus consistency_level must be one of: "
+ f"{sorted(allowed)}; got {self.consistency_level!r}"
+ )
+ self.consistency_level = title_value
+ self.dense_vector_name = (self.dense_vector_name or "vector").strip()
+ self.sparse_vector_name = (self.sparse_vector_name or "sparse_vector").strip()
+ if self.timeout_seconds <= 0:
+ raise ValueError("Milvus timeout_seconds must be positive")
+ return self
+
_OPENGAUSS_MODES = {"standalone", "distributed"}
@@ -214,7 +267,7 @@ class VectorDBBackendConfig(BaseModel):
description=(
"VectorDB backend type: 'local', 'cuvs', 'http', "
"'volcengine' (AK/SK signed or API key data-plane only), "
- "'vikingdb' (private deployment), 'qdrant', or 'opengauss'"
+ "'vikingdb' (private deployment), 'qdrant', 'milvus', or 'opengauss'"
),
)
@@ -278,6 +331,11 @@ class VectorDBBackendConfig(BaseModel):
description="NVIDIA cuVS dense-vector search configuration for the 'cuvs' backend",
)
+ milvus: Optional[MilvusConfig] = Field(
+ default_factory=MilvusConfig,
+ description="Milvus configuration for 'milvus' type",
+ )
+
opengauss: Optional[OpenGaussConfig] = Field(
default_factory=OpenGaussConfig,
description="openGauss configuration for 'opengauss' type",
@@ -300,6 +358,7 @@ def validate_config(self):
"volcengine",
"vikingdb",
"qdrant",
+ "milvus",
"opengauss",
]
@@ -364,6 +423,21 @@ def validate_config(self):
if self.url:
self.url = self.url.strip().rstrip("/")
+ elif self.backend == "milvus":
+ milvus_uri = (
+ (self.milvus.uri if self.milvus else None)
+ or self.url
+ or self.custom_params.get("uri")
+ or "./milvus.db"
+ )
+ if self.milvus is None:
+ self.milvus = MilvusConfig()
+ self.milvus.uri = str(milvus_uri).strip()
+ if self.milvus.uri.startswith(("http://", "https://")):
+ self.milvus.uri = self.milvus.uri.rstrip("/")
+ if self.url:
+ self.url = self.url.strip().rstrip("/")
+
elif self.backend == "opengauss":
if self.opengauss is None:
self.opengauss = OpenGaussConfig()
diff --git a/pyproject.toml b/pyproject.toml
index 6d932adecb..0258978d25 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -106,6 +106,9 @@ test = [
opengauss = [
"psycopg2-binary>=2.9",
]
+milvus = [
+ "pymilvus[milvus_lite]>=2.6.0",
+]
dev = [
"mypy>=1.0.0",
"ruff>=0.1.0",
diff --git a/tests/storage/test_milvus_adapter.py b/tests/storage/test_milvus_adapter.py
new file mode 100644
index 0000000000..4f7462f6b2
--- /dev/null
+++ b/tests/storage/test_milvus_adapter.py
@@ -0,0 +1,375 @@
+# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd.
+# SPDX-License-Identifier: AGPL-3.0
+
+from __future__ import annotations
+
+import importlib.util
+import uuid
+from datetime import datetime, timezone
+
+import pytest
+
+from openviking.storage.expr import And, Contains, Eq, PathScope, TimeRange
+from openviking.storage.vectordb_adapters.factory import create_collection_adapter
+from openviking.storage.vectordb_adapters.milvus_adapter import (
+ MilvusCollection,
+ MilvusCollectionAdapter,
+ MilvusFilterCompiler,
+ _encode_scope_roots,
+ _normalize_distance,
+ _safe_collection_name,
+)
+from openviking_cli.utils.config.vectordb_config import VectorDBBackendConfig
+
+
+def _build_config() -> VectorDBBackendConfig:
+ return VectorDBBackendConfig.model_validate(
+ {
+ "backend": "milvus",
+ "project": "default",
+ "name": "context",
+ "index_name": "default",
+ "distance_metric": "cosine",
+ "milvus": {
+ "uri": "./milvus.db",
+ "token": "test-token",
+ "db_name": "default",
+ "consistency_level": "session",
+ "timeout_seconds": 7,
+ "dense_vector_name": "vector",
+ "sparse_vector_name": "sparse_vector",
+ },
+ }
+ )
+
+
+def _schema() -> dict:
+ return {
+ "CollectionName": "context",
+ "Description": "test collection",
+ "Fields": [
+ {"FieldName": "id", "FieldType": "string", "IsPrimaryKey": True},
+ {"FieldName": "uri", "FieldType": "path"},
+ {"FieldName": "vector", "FieldType": "vector", "Dim": 2},
+ {"FieldName": "sparse_vector", "FieldType": "sparse_vector"},
+ {"FieldName": "abstract", "FieldType": "string"},
+ {"FieldName": "level", "FieldType": "int64"},
+ {"FieldName": "updated_at", "FieldType": "date_time"},
+ {"FieldName": "search_tags", "FieldType": "list"},
+ {"FieldName": "account_id", "FieldType": "string"},
+ ],
+ "ScalarIndex": ["uri", "level", "updated_at", "search_tags", "account_id"],
+ }
+
+
+def test_milvus_backend_config_validation():
+ config = _build_config()
+
+ assert config.backend == "milvus"
+ assert config.milvus is not None
+ assert config.milvus.uri == "./milvus.db"
+ assert config.milvus.token == "test-token"
+ assert config.milvus.db_name == "default"
+ assert config.milvus.consistency_level == "Session"
+
+
+def test_factory_creates_milvus_adapter_without_connecting():
+ adapter = create_collection_adapter(_build_config())
+
+ assert isinstance(adapter, MilvusCollectionAdapter)
+ assert adapter.mode == "milvus"
+ assert adapter.collection_name == "context"
+ assert adapter.index_name == "default"
+ assert adapter.physical_collection_name == "ov_default_context"
+
+
+def test_augments_path_fields_on_write_and_hides_them_on_read():
+ adapter = MilvusCollectionAdapter.from_config(_build_config())
+ source_record = {
+ "id": "1",
+ "uri": "viking://resources/acme/docs/a.md",
+ "vector": [0.1, 0.2],
+ }
+
+ normalized = adapter._normalize_record_for_write(source_record)
+
+ assert normalized["uri"] == "/resources/acme/docs/a.md"
+ assert normalized["parent_uri"] == "/resources/acme/docs"
+ assert normalized["scope_roots"] == [
+ "/",
+ "/resources",
+ "/resources/acme",
+ "/resources/acme/docs",
+ ]
+ assert normalized["uri_depth"] == 4
+ assert source_record["uri"] == "viking://resources/acme/docs/a.md"
+
+ public_record = adapter.normalize_record_for_read(normalized)
+ assert public_record["uri"] == "viking://resources/acme/docs/a.md"
+ assert "parent_uri" not in public_record
+ assert "scope_roots" not in public_record
+ assert "uri_depth" not in public_record
+
+
+def test_compiles_filter_exprs():
+ compiler = MilvusFilterCompiler(
+ {
+ "account_id": "string",
+ "scope_roots": "string",
+ "updated_at": "date_time",
+ "abstract": "string",
+ }
+ )
+
+ compiled = compiler.compile(
+ And(
+ [
+ Eq("account_id", "acme"),
+ PathScope("uri", "viking://resources/acme/docs", depth=-1),
+ TimeRange(
+ "updated_at",
+ start=datetime(2026, 5, 1, tzinfo=timezone.utc),
+ end=datetime(2026, 6, 1, tzinfo=timezone.utc),
+ ),
+ Contains("abstract", "quarterly report"),
+ ]
+ )
+ )
+
+ assert compiled == (
+ '(account_id == "acme") and '
+ '(scope_roots like "%\\n/resources/acme/docs\\n%") and '
+ '(updated_at >= "2026-05-01T00:00:00+00:00" and '
+ 'updated_at < "2026-06-01T00:00:00+00:00") and '
+ '(abstract like "%quarterly report%")'
+ )
+
+
+def test_compiles_legacy_dict_filters():
+ compiler = MilvusFilterCompiler(
+ {
+ "account_id": "string",
+ "updated_at": "date_time",
+ "scope_roots": "string",
+ }
+ )
+
+ compiled = compiler.compile(
+ {
+ "op": "and",
+ "conds": [
+ {"op": "must", "field": "account_id", "conds": ["acme"]},
+ {
+ "op": "time_range",
+ "field": "updated_at",
+ "gte": "2026-05-01T00:00:00Z",
+ "lt": "2026-06-01T00:00:00Z",
+ },
+ {"op": "prefix", "field": "uri", "prefix": "viking://resources/acme/docs"},
+ ],
+ }
+ )
+
+ assert compiled == (
+ '(account_id == "acme") and '
+ '(updated_at >= "2026-05-01T00:00:00Z" and '
+ 'updated_at < "2026-06-01T00:00:00Z") and '
+ '(scope_roots like "%\\n/resources/acme/docs\\n%")'
+ )
+
+
+def test_vector_literal_and_collection_name_safety():
+ name = _safe_collection_name("Project/With Space", "Context.Table")
+
+ assert name.startswith("ov_Project_With_Space_Context_Table")
+ assert len(name) <= 255
+ assert _normalize_distance("ip") == "ip"
+
+ with pytest.raises(ValueError, match="supports only cosine, l2, and ip"):
+ _normalize_distance("dot")
+
+
+def test_scope_roots_encoding_is_token_safe():
+ encoded = _encode_scope_roots(["/a", "/a/b"])
+
+ assert encoded == "\n/a\n/a/b\n"
+ assert "\n/a\n" in encoded
+ assert "\n/a/b\n" in encoded
+ assert "\n/a/c\n" not in encoded
+
+
+def test_score_from_cosine_distance_is_higher_is_better():
+ from openviking.storage.vectordb_adapters.milvus_adapter import _score_from_hit
+
+ assert _score_from_hit({"distance": 0.0}, "cosine") == pytest.approx(1.0)
+ assert _score_from_hit({"distance": 1.0}, "cosine") == pytest.approx(0.0)
+
+
+class _FakeSchema:
+ def __init__(self) -> None:
+ self.fields = []
+
+ def add_field(self, **kwargs):
+ self.fields.append(kwargs)
+
+
+class _FakeIndexParams:
+ def __init__(self) -> None:
+ self.indexes = []
+
+ def add_index(self, **kwargs):
+ self.indexes.append(kwargs)
+
+
+class _FakeMilvusClient:
+ def __init__(self) -> None:
+ self.schema = _FakeSchema()
+ self.index_params = _FakeIndexParams()
+ self.created_collection = None
+ self.created_index = None
+ self.properties = {}
+ self.loaded = False
+
+ def create_schema(self, **kwargs):
+ self.schema_kwargs = kwargs
+ return self.schema
+
+ def create_collection(self, **kwargs):
+ self.created_collection = kwargs
+
+ def alter_collection_properties(self, collection_name, properties, timeout=None):
+ self.properties.update(properties)
+
+ def prepare_index_params(self):
+ return self.index_params
+
+ def create_index(self, **kwargs):
+ self.created_index = kwargs
+
+ def load_collection(self, collection_name, timeout=None):
+ self.loaded = True
+
+
+def test_collection_creation_uses_explicit_schema_and_autoindex():
+ client = _FakeMilvusClient()
+ collection = MilvusCollection(
+ client=client,
+ logical_collection_name="context",
+ physical_collection_name="ov_default_context",
+ project_name="default",
+ dense_vector_name="vector",
+ sparse_vector_name="sparse_vector",
+ distance_metric="cosine",
+ timeout_seconds=7,
+ meta=_schema(),
+ )
+
+ collection.create_remote_collection(_schema(), consistency_level="Session")
+ collection.create_index(
+ "default",
+ {
+ "IndexName": "default",
+ "VectorIndex": {"IndexType": "AUTOINDEX", "Distance": "cosine"},
+ "ScalarIndex": ["uri", "level", "parent_uri", "scope_roots"],
+ },
+ )
+
+ assert client.schema_kwargs["auto_id"] is False
+ assert client.schema_kwargs["enable_dynamic_field"] is True
+ field_names = {field["field_name"] for field in client.schema.fields}
+ assert {"id", "uri", "vector", "sparse_vector", "parent_uri", "scope_roots"} <= field_names
+ vector_field = next(field for field in client.schema.fields if field["field_name"] == "vector")
+ assert vector_field["dim"] == 2
+ assert client.created_collection["collection_name"] == "ov_default_context"
+ assert client.created_collection["consistency_level"] == "Session"
+ assert client.index_params.indexes[0] == {
+ "field_name": "vector",
+ "index_name": "default",
+ "index_type": "AUTOINDEX",
+ "metric_type": "COSINE",
+ }
+ assert client.loaded is True
+
+
+@pytest.mark.skipif(
+ importlib.util.find_spec("milvus_lite") is None,
+ reason="milvus_lite is not installed",
+)
+def test_milvus_lite_adapter_integration_smoke(tmp_path):
+ pytest.importorskip("pymilvus")
+
+ suffix = uuid.uuid4().hex[:8]
+ uri = str(tmp_path / "milvus.db")
+ project_name = f"pytest_{suffix}"
+
+ def _new_adapter() -> MilvusCollectionAdapter:
+ return MilvusCollectionAdapter(
+ uri=uri,
+ token=None,
+ db_name=None,
+ consistency_level="Strong",
+ timeout_seconds=30,
+ project_name=project_name,
+ collection_name="context",
+ index_name="default",
+ distance_metric="cosine",
+ dense_vector_name="vector",
+ sparse_vector_name="sparse_vector",
+ )
+
+ adapter = _new_adapter()
+
+ try:
+ assert adapter.create_collection(
+ "context",
+ _schema(),
+ distance="cosine",
+ sparse_weight=0.0,
+ index_name="default",
+ )
+ adapter.upsert(
+ [
+ {
+ "id": "doc-1",
+ "uri": "viking://resources/acme/docs/a.md",
+ "vector": [1.0, 0.0],
+ "sparse_vector": {"quarter": 1.0},
+ "abstract": "quarterly report",
+ "level": 1,
+ "updated_at": "2026-05-15T00:00:00+00:00",
+ "search_tags": ["finance"],
+ "account_id": "acme",
+ },
+ {
+ "id": "doc-2",
+ "uri": "viking://resources/acme/notes/b.md",
+ "vector": [0.0, 1.0],
+ "sparse_vector": {"notes": 1.0},
+ "abstract": "meeting notes",
+ "level": 2,
+ "updated_at": "2026-05-16T00:00:00+00:00",
+ "search_tags": ["notes"],
+ "account_id": "acme",
+ },
+ ]
+ )
+
+ adapter.close()
+ adapter = _new_adapter()
+ assert adapter.collection_exists() is True
+
+ result = adapter.query(
+ query_vector=[1.0, 0.0],
+ limit=1,
+ filter=PathScope("uri", "viking://resources/acme/docs", depth=-1),
+ output_fields=["id", "uri", "abstract", "level"],
+ )
+ assert [item["id"] for item in result] == ["doc-1"]
+ assert result[0]["_score"] == pytest.approx(1.0)
+ assert adapter.count(Eq("account_id", "missing")) == 0
+ assert adapter.count() == 2
+ assert adapter.delete(ids=["doc-2"]) == 1
+ assert adapter.count() == 1
+ finally:
+ adapter.drop_collection()
+ adapter.close()
diff --git a/uv.lock b/uv.lock
index 0be3eae181..e8ecbbd7a8 100644
--- a/uv.lock
+++ b/uv.lock
@@ -526,6 +526,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c5/0d/84a4380f930db0010168e0aa7b7a8fed9ba1835a8fbb1472bc6d0201d529/build-1.4.0-py3-none-any.whl", hash = "sha256:6a07c1b8eb6f2b311b96fcbdbce5dab5fe637ffda0fd83c9cac622e927501596", size = 24141, upload-time = "2026-01-08T16:41:46.453Z" },
]
+[[package]]
+name = "cachetools"
+version = "7.1.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f4/8b/0d3945a13955303b81272f759a0331e54c5c793da455e6f5706b89d2639c/cachetools-7.1.4.tar.gz", hash = "sha256:437f55a4e0c1b01a4f3077cc470e6991d47430970e36fbcb77e2be0df4fc1cd6", size = 40085, upload-time = "2026-05-21T22:40:43.376Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8c/7b/1fc1c09cc0756cf25861a3be10565915953876da48bb228fb9a672b20a42/cachetools-7.1.4-py3-none-any.whl", hash = "sha256:323dc4127934744db5b54eb4924482d7edafbf9554e820d1531c2e08c0e4ef54", size = 16761, upload-time = "2026-05-21T22:40:41.845Z" },
+]
+
[[package]]
name = "certifi"
version = "2026.2.25"
@@ -1228,7 +1237,7 @@ name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "typing-extensions", marker = "python_full_version < '3.11'" },
+ { name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [
@@ -1244,6 +1253,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" },
]
+[[package]]
+name = "faiss-cpu"
+version = "1.14.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+ { name = "packaging" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/83/b0/48c083d01b7b68c463c1d56507147a9d733f791e1c469a77215a872a9fb5/faiss_cpu-1.14.3-cp310-abi3-macosx_14_0_arm64.whl", hash = "sha256:a9369863290a3f0e033757e4c10577b6ef7431f1cede394dabd0a137e4e2ed45", size = 4768290, upload-time = "2026-06-13T02:19:03.427Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/34/6b04ef5bae3eada6b5a9457d7875cce041c040d53c890815cbd1e9821c65/faiss_cpu-1.14.3-cp310-abi3-macosx_15_0_x86_64.whl", hash = "sha256:f9d0e84d909194f63f027bbd3c1e35e905e48c9345c2db6e6f24da09a6bc5906", size = 6925734, upload-time = "2026-06-13T02:19:05.232Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/8a/b451af4b3c6dd18749ecfb58ccb68503b77e49c9aa4a89d950e9d521e058/faiss_cpu-1.14.3-cp310-abi3-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d734cfa9ac90b6a5dfed3a27cb706d05f22824703dafc3969b4e2071877a31c", size = 9661210, upload-time = "2026-06-13T02:19:06.99Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/ed/57335bc18c9e18677587bec9bf070c675b29c8e683e13f4def0440731ca0/faiss_cpu-1.14.3-cp310-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8780b526c06e57aad90a8c4655dfba2ff1b3195bd600ff4499752fc45159c9fc", size = 18506292, upload-time = "2026-06-13T02:19:09.621Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/d3/c6ca8c44a63b909e78aa8a69e14501c79c89613e62261d58455ea603d710/faiss_cpu-1.14.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b28ba083e8c02f2c9be03783402537fd3f00d27e68799c44bf88931500ee12ec", size = 11238800, upload-time = "2026-06-13T02:19:12.821Z" },
+ { url = "https://files.pythonhosted.org/packages/93/5f/b405692913a301251749cb175cb3f564ed257fdaa80a22c9a36444d0095d/faiss_cpu-1.14.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:cdcb90850cb4b7c27d270839b37bcc0dc8d1fb6a62d1e13053e51ac95061ca25", size = 19237637, upload-time = "2026-06-13T02:19:15.531Z" },
+]
+
[[package]]
name = "fastapi"
version = "0.135.1"
@@ -2192,13 +2219,13 @@ name = "langchain-classic"
version = "1.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "langchain-core", marker = "python_full_version >= '3.11'" },
- { name = "langchain-text-splitters", marker = "python_full_version >= '3.11'" },
- { name = "langsmith", marker = "python_full_version >= '3.11'" },
- { name = "pydantic", marker = "python_full_version >= '3.11'" },
- { name = "pyyaml", marker = "python_full_version >= '3.11'" },
- { name = "requests", marker = "python_full_version >= '3.11'" },
- { name = "sqlalchemy", marker = "python_full_version >= '3.11'" },
+ { name = "langchain-core" },
+ { name = "langchain-text-splitters" },
+ { name = "langsmith" },
+ { name = "pydantic" },
+ { name = "pyyaml" },
+ { name = "requests" },
+ { name = "sqlalchemy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/21/6f/59da67274d8ceea16d0610142af33e348a24750894f08c0688de01504ff2/langchain_classic-1.0.2.tar.gz", hash = "sha256:bbf686613d0051905794f2646ecb6a79fa398db399750a4af039107d93054335", size = 10533928, upload-time = "2026-03-06T20:19:46.176Z" }
wheels = [
@@ -2213,18 +2240,18 @@ resolution-markers = [
"python_full_version < '3.11'",
]
dependencies = [
- { name = "aiohttp", marker = "python_full_version < '3.11'" },
- { name = "dataclasses-json", marker = "python_full_version < '3.11'" },
- { name = "httpx-sse", marker = "python_full_version < '3.11'" },
- { name = "langchain", marker = "python_full_version < '3.11'" },
- { name = "langchain-core", marker = "python_full_version < '3.11'" },
- { name = "langsmith", marker = "python_full_version < '3.11'" },
- { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
- { name = "pydantic-settings", marker = "python_full_version < '3.11'" },
- { name = "pyyaml", marker = "python_full_version < '3.11'" },
- { name = "requests", marker = "python_full_version < '3.11'" },
- { name = "sqlalchemy", marker = "python_full_version < '3.11'" },
- { name = "tenacity", marker = "python_full_version < '3.11'" },
+ { name = "aiohttp" },
+ { name = "dataclasses-json" },
+ { name = "httpx-sse" },
+ { name = "langchain" },
+ { name = "langchain-core" },
+ { name = "langsmith" },
+ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } },
+ { name = "pydantic-settings" },
+ { name = "pyyaml" },
+ { name = "requests" },
+ { name = "sqlalchemy" },
+ { name = "tenacity" },
]
sdist = { url = "https://files.pythonhosted.org/packages/83/49/2ff5354273809e9811392bc24bcffda545a196070666aef27bc6aacf1c21/langchain_community-0.3.31.tar.gz", hash = "sha256:250e4c1041539130f6d6ac6f9386cb018354eafccd917b01a4cff1950b80fd81", size = 33241237, upload-time = "2025-10-07T20:17:57.857Z" }
wheels = [
@@ -2250,18 +2277,18 @@ resolution-markers = [
"python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
]
dependencies = [
- { name = "aiohttp", marker = "python_full_version >= '3.11'" },
- { name = "dataclasses-json", marker = "python_full_version >= '3.11'" },
- { name = "httpx-sse", marker = "python_full_version >= '3.11'" },
- { name = "langchain-classic", marker = "python_full_version >= '3.11'" },
- { name = "langchain-core", marker = "python_full_version >= '3.11'" },
- { name = "langsmith", marker = "python_full_version >= '3.11'" },
- { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
- { name = "pydantic-settings", marker = "python_full_version >= '3.11'" },
- { name = "pyyaml", marker = "python_full_version >= '3.11'" },
- { name = "requests", marker = "python_full_version >= '3.11'" },
- { name = "sqlalchemy", marker = "python_full_version >= '3.11'" },
- { name = "tenacity", marker = "python_full_version >= '3.11'" },
+ { name = "aiohttp" },
+ { name = "dataclasses-json" },
+ { name = "httpx-sse" },
+ { name = "langchain-classic" },
+ { name = "langchain-core" },
+ { name = "langsmith" },
+ { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" } },
+ { name = "pydantic-settings" },
+ { name = "pyyaml" },
+ { name = "requests" },
+ { name = "sqlalchemy" },
+ { name = "tenacity" },
]
sdist = { url = "https://files.pythonhosted.org/packages/53/97/a03585d42b9bdb6fbd935282d6e3348b10322a24e6ce12d0c99eb461d9af/langchain_community-0.4.1.tar.gz", hash = "sha256:f3b211832728ee89f169ddce8579b80a085222ddb4f4ed445a46e977d17b1e85", size = 33241144, upload-time = "2025-10-27T15:20:32.504Z" }
wheels = [
@@ -2306,7 +2333,7 @@ name = "langchain-text-splitters"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "langchain-core", marker = "python_full_version >= '3.11'" },
+ { name = "langchain-core" },
]
sdist = { url = "https://files.pythonhosted.org/packages/85/38/14121ead61e0e75f79c3a35e5148ac7c2fe754a55f76eab3eed573269524/langchain_text_splitters-1.1.1.tar.gz", hash = "sha256:34861abe7c07d9e49d4dc852d0129e26b32738b60a74486853ec9b6d6a8e01d2", size = 279352, upload-time = "2026-02-18T23:02:42.798Z" }
wheels = [
@@ -2702,7 +2729,7 @@ resolution-markers = [
"python_full_version < '3.11'",
]
dependencies = [
- { name = "mdurl", marker = "python_full_version < '3.11'" },
+ { name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" }
wheels = [
@@ -2728,7 +2755,7 @@ resolution-markers = [
"python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
]
dependencies = [
- { name = "mdurl", marker = "python_full_version >= '3.11'" },
+ { name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
wheels = [
@@ -2879,6 +2906,23 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
+[[package]]
+name = "milvus-lite"
+version = "3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "faiss-cpu" },
+ { name = "grpcio" },
+ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+ { name = "pyarrow" },
+ { name = "tomli", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a3/9a/d80d260e6fe1246818a8ef782c374ba9c6ca46ca3b987c14eabe914ef805/milvus_lite-3.0.tar.gz", hash = "sha256:2c35d0d046b1faae3402cde1fb73d65f51ee8c6aba65f54de1dda46f7bb18b9b", size = 589749, upload-time = "2026-05-13T07:14:05.827Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/59/42/dee08ae3bfc1731572f193d00b248c9370b0b9dff12becb0ffd8b2ee8d56/milvus_lite-3.0-py3-none-any.whl", hash = "sha256:d9a094eab84bdaa4253da3721482282c939da1cce6f4e1759f947e8d3e53406e", size = 230490, upload-time = "2026-05-13T07:14:00.816Z" },
+]
+
[[package]]
name = "msgpack"
version = "1.1.2"
@@ -3164,12 +3208,12 @@ resolution-markers = [
"python_full_version < '3.11'",
]
dependencies = [
- { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
- { name = "jinja2", marker = "python_full_version < '3.11'" },
- { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
- { name = "mdit-py-plugins", marker = "python_full_version < '3.11'" },
- { name = "pyyaml", marker = "python_full_version < '3.11'" },
- { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" } },
+ { name = "jinja2" },
+ { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" } },
+ { name = "mdit-py-plugins" },
+ { name = "pyyaml" },
+ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" } },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/a5/9626ba4f73555b3735ad86247a8077d4603aa8628537687c839ab08bfe44/myst_parser-4.0.1.tar.gz", hash = "sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4", size = 93985, upload-time = "2025-02-12T10:53:03.833Z" }
wheels = [
@@ -3195,12 +3239,12 @@ resolution-markers = [
"python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
]
dependencies = [
- { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
- { name = "jinja2", marker = "python_full_version >= '3.11'" },
- { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
- { name = "mdit-py-plugins", marker = "python_full_version >= '3.11'" },
- { name = "pyyaml", marker = "python_full_version >= '3.11'" },
- { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
+ { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" } },
+ { name = "jinja2" },
+ { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" } },
+ { name = "mdit-py-plugins" },
+ { name = "pyyaml" },
+ { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/33/fa/7b45eef11b7971f0beb29d27b7bfe0d747d063aa29e170d9edd004733c8a/myst_parser-5.0.0.tar.gz", hash = "sha256:f6f231452c56e8baa662cc352c548158f6a16fcbd6e3800fc594978002b94f3a", size = 98535, upload-time = "2026-01-15T09:08:18.036Z" }
@@ -3679,35 +3723,6 @@ benchmark = [
{ name = "tiktoken" },
]
bot = [
- { name = "beautifulsoup4" },
- { name = "croniter" },
- { name = "ddgs" },
- { name = "gradio" },
- { name = "html2text" },
- { name = "httpx", extra = ["socks"] },
- { name = "mcp" },
- { name = "msgpack" },
- { name = "prompt-toolkit" },
- { name = "py-machineid" },
- { name = "pydantic-settings" },
- { name = "pygments" },
- { name = "python-engineio" },
- { name = "python-socketio" },
- { name = "python-socks", extra = ["asyncio"] },
- { name = "readability-lxml" },
- { name = "rich" },
- { name = "socksio" },
- { name = "tavily-python" },
- { name = "websocket-client" },
- { name = "websockets" },
-]
-bot-dingtalk = [
- { name = "dingtalk-stream" },
-]
-bot-feishu = [
- { name = "lark-oapi" },
-]
-bot-full = [
{ name = "agent-sandbox" },
{ name = "beautifulsoup4" },
{ name = "croniter" },
@@ -3741,29 +3756,6 @@ bot-full = [
{ name = "websocket-client" },
{ name = "websockets" },
]
-bot-fuse = [
- { name = "fusepy" },
-]
-bot-langfuse = [
- { name = "langfuse" },
-]
-bot-opencode = [
- { name = "opencode-ai" },
-]
-bot-qq = [
- { name = "qq-botpy" },
-]
-bot-sandbox = [
- { name = "agent-sandbox" },
- { name = "opensandbox" },
- { name = "opensandbox-server" },
-]
-bot-slack = [
- { name = "slack-sdk" },
-]
-bot-telegram = [
- { name = "python-telegram-bot", extra = ["socks"] },
-]
build = [
{ name = "build" },
{ name = "cmake" },
@@ -3808,6 +3800,9 @@ langgraph = [
local-embed = [
{ name = "llama-cpp-python" },
]
+milvus = [
+ { name = "pymilvus", extra = ["milvus-lite"] },
+]
ocr = [
{ name = "pytesseract" },
]
@@ -3835,7 +3830,7 @@ dev = [
[package.metadata]
requires-dist = [
- { name = "agent-sandbox", marker = "extra == 'bot-sandbox'", specifier = ">=0.0.23" },
+ { name = "agent-sandbox", marker = "extra == 'bot'", specifier = ">=0.0.23" },
{ name = "anyio", marker = "extra == 'gemini-async'", specifier = ">=4.0.0" },
{ name = "apscheduler", specifier = ">=3.11.0" },
{ name = "argon2-cffi", specifier = ">=23.0.0" },
@@ -3852,11 +3847,11 @@ requires-dist = [
{ name = "ddgs", marker = "extra == 'bot'", specifier = ">=9.0.0" },
{ name = "defusedxml", specifier = ">=0.7.1" },
{ name = "diff-match-patch", marker = "extra == 'test'", specifier = ">=20200713" },
- { name = "dingtalk-stream", marker = "extra == 'bot-dingtalk'", specifier = ">=0.4.0" },
+ { name = "dingtalk-stream", marker = "extra == 'bot'", specifier = ">=0.4.0" },
{ name = "ebooklib", specifier = ">=0.18.0" },
{ name = "fastapi", specifier = ">=0.128.0" },
{ name = "feedparser", specifier = ">=6.0.0" },
- { name = "fusepy", marker = "extra == 'bot-fuse'", specifier = ">=3.0.1" },
+ { name = "fusepy", marker = "extra == 'bot'", specifier = ">=3.0.1" },
{ name = "google-genai", marker = "extra == 'gemini'", specifier = ">=1.0.0" },
{ name = "google-genai", marker = "extra == 'gemini-async'", specifier = ">=1.0.0" },
{ name = "gradio", marker = "extra == 'bot'", specifier = ">=6.6.0" },
@@ -3872,11 +3867,11 @@ requires-dist = [
{ name = "langchain-core", marker = "extra == 'langchain'", specifier = ">=1.0.0,<2.0.0" },
{ name = "langchain-core", marker = "extra == 'langgraph'", specifier = ">=1.0.0,<2.0.0" },
{ name = "langchain-openai", marker = "extra == 'benchmark'", specifier = ">=1.0.0" },
- { name = "langfuse", marker = "extra == 'bot-langfuse'", specifier = ">=3.0.0" },
+ { name = "langfuse", marker = "extra == 'bot'", specifier = ">=3.0.0" },
{ name = "langgraph", marker = "extra == 'langgraph'", specifier = ">=1.0.0,<2.0.0" },
{ name = "lark-oapi", specifier = ">=1.5.3" },
- { name = "lark-oapi", marker = "extra == 'bot-feishu'", specifier = ">=1.0.0" },
- { name = "litellm", specifier = ">=1.83.7,<1.89.3" },
+ { name = "lark-oapi", marker = "extra == 'bot'", specifier = ">=1.0.0" },
+ { name = "litellm", specifier = ">=1.83.7,<1.90.3" },
{ name = "llama-cpp-python", marker = "extra == 'local-embed'", specifier = ">=0.3.0" },
{ name = "loguru", specifier = ">=0.7.3" },
{ name = "mcp", specifier = ">=1.27.0" },
@@ -3886,16 +3881,15 @@ requires-dist = [
{ name = "myst-parser", marker = "extra == 'doc'", specifier = ">=2.0.0" },
{ name = "olefile", specifier = ">=0.47" },
{ name = "openai", specifier = ">=1.0.0" },
- { name = "opencode-ai", marker = "extra == 'bot-opencode'", specifier = ">=0.1.0a0" },
+ { name = "opencode-ai", marker = "extra == 'bot'", specifier = ">=0.1.0a0" },
{ name = "openpyxl", specifier = ">=3.0.0" },
- { name = "opensandbox", marker = "extra == 'bot-sandbox'", specifier = ">=0.1.0" },
- { name = "opensandbox-server", marker = "extra == 'bot-sandbox'", specifier = ">=0.1.0" },
+ { name = "opensandbox", marker = "extra == 'bot'", specifier = ">=0.1.0" },
+ { name = "opensandbox-server", marker = "extra == 'bot'", specifier = ">=0.1.0" },
{ name = "opentelemetry-api", specifier = ">=1.14" },
{ name = "opentelemetry-exporter-otlp-proto-grpc", specifier = ">=1.14" },
{ name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.14" },
{ name = "opentelemetry-instrumentation-asyncio", specifier = ">=0.61b0" },
{ name = "opentelemetry-sdk", specifier = ">=1.14" },
- { name = "openviking", extras = ["bot", "bot-dingtalk", "bot-feishu", "bot-fuse", "bot-langfuse", "bot-opencode", "bot-qq", "bot-sandbox", "bot-slack", "bot-telegram"], marker = "extra == 'bot-full'" },
{ name = "openviking-sdk", specifier = ">=0.1.1" },
{ name = "pandas", marker = "extra == 'benchmark'", specifier = ">=2.0.0" },
{ name = "pandas", marker = "extra == 'eval'", specifier = ">=2.0.0" },
@@ -3910,6 +3904,7 @@ requires-dist = [
{ name = "pydantic", specifier = ">=2.0.0" },
{ name = "pydantic-settings", marker = "extra == 'bot'", specifier = ">=2.0.0" },
{ name = "pygments", marker = "extra == 'bot'", specifier = ">=2.16.0" },
+ { name = "pymilvus", extras = ["milvus-lite"], marker = "extra == 'milvus'", specifier = ">=2.6.0" },
{ name = "pytesseract", marker = "extra == 'ocr'", specifier = ">=0.3.10" },
{ name = "pytest", marker = "extra == 'test'", specifier = ">=7.0.0" },
{ name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.21.0" },
@@ -3921,9 +3916,9 @@ requires-dist = [
{ name = "python-pptx", specifier = ">=1.0.0" },
{ name = "python-socketio", marker = "extra == 'bot'", specifier = ">=5.16.2" },
{ name = "python-socks", extras = ["asyncio"], marker = "extra == 'bot'", specifier = ">=2.4.0" },
- { name = "python-telegram-bot", extras = ["socks"], marker = "extra == 'bot-telegram'", specifier = ">=21.0" },
+ { name = "python-telegram-bot", extras = ["socks"], marker = "extra == 'bot'", specifier = ">=21.0" },
{ name = "pyyaml", specifier = ">=6.0" },
- { name = "qq-botpy", marker = "extra == 'bot-qq'", specifier = ">=1.0.0" },
+ { name = "qq-botpy", marker = "extra == 'bot'", specifier = ">=1.0.0" },
{ name = "ragas", marker = "extra == 'eval'", specifier = ">=0.1.0" },
{ name = "ragas", marker = "extra == 'test'", specifier = ">=0.1.0" },
{ name = "readability-lxml", marker = "extra == 'bot'", specifier = ">=0.8.0" },
@@ -3934,7 +3929,7 @@ requires-dist = [
{ name = "setuptools", marker = "extra == 'build'", specifier = ">=61.0" },
{ name = "setuptools-scm", marker = "extra == 'build'", specifier = ">=8.0" },
{ name = "setuptools-scm", marker = "extra == 'dev'", specifier = ">=10.0.0" },
- { name = "slack-sdk", marker = "extra == 'bot-slack'", specifier = ">=3.26.0" },
+ { name = "slack-sdk", marker = "extra == 'bot'", specifier = ">=3.26.0" },
{ name = "socksio", marker = "extra == 'bot'", specifier = ">=1.0.0" },
{ name = "sphinx", marker = "extra == 'doc'", specifier = ">=7.0.0" },
{ name = "sphinx-rtd-theme", marker = "extra == 'doc'", specifier = ">=1.3.0" },
@@ -3965,7 +3960,7 @@ requires-dist = [
{ name = "xlrd", specifier = ">=2.0.1" },
{ name = "xxhash", specifier = ">=3.0.0" },
]
-provides-extras = ["test", "opengauss", "dev", "doc", "eval", "gemini", "gemini-async", "ocr", "build", "bot", "bot-langfuse", "bot-telegram", "bot-feishu", "bot-dingtalk", "bot-slack", "bot-qq", "bot-sandbox", "bot-fuse", "bot-opencode", "bot-full", "benchmark", "langchain", "langgraph", "local-embed"]
+provides-extras = ["test", "opengauss", "milvus", "dev", "doc", "eval", "gemini", "gemini-async", "ocr", "build", "bot", "benchmark", "langchain", "langgraph", "local-embed"]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=9.0.2" }]
@@ -4136,10 +4131,10 @@ resolution-markers = [
"python_full_version < '3.11'",
]
dependencies = [
- { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
- { name = "python-dateutil", marker = "python_full_version < '3.11'" },
- { name = "pytz", marker = "python_full_version < '3.11'" },
- { name = "tzdata", marker = "python_full_version < '3.11'" },
+ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } },
+ { name = "python-dateutil" },
+ { name = "pytz" },
+ { name = "tzdata" },
]
sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" }
wheels = [
@@ -4211,9 +4206,9 @@ resolution-markers = [
"python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
]
dependencies = [
- { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
- { name = "python-dateutil", marker = "python_full_version >= '3.11'" },
- { name = "tzdata", marker = "(python_full_version >= '3.11' and sys_platform == 'emscripten') or (python_full_version >= '3.11' and sys_platform == 'win32')" },
+ { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" } },
+ { name = "python-dateutil" },
+ { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2e/0c/b28ed414f080ee0ad153f848586d61d1878f91689950f037f976ce15f6c8/pandas-3.0.1.tar.gz", hash = "sha256:4186a699674af418f655dbd420ed87f50d56b4cd6603784279d9eef6627823c8", size = 4641901, upload-time = "2026-02-17T22:20:16.434Z" }
wheels = [
@@ -5010,6 +5005,30 @@ crypto = [
{ name = "cryptography" },
]
+[[package]]
+name = "pymilvus"
+version = "3.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cachetools" },
+ { name = "grpcio" },
+ { name = "orjson" },
+ { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "pandas", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+ { name = "protobuf" },
+ { name = "python-dotenv" },
+ { name = "requests" },
+ { name = "setuptools" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ac/c1/01647e61f3a82fd881382746b6dde3401d65b88cd4f75bd059901fb2392b/pymilvus-3.0.0-1-py3-none-any.whl", hash = "sha256:57c8e7c87fbbf579f122b4df893949bc78e50bca2988527864891bd544817b05", size = 344817, upload-time = "2026-05-07T14:57:45.235Z" },
+]
+
+[package.optional-dependencies]
+milvus-lite = [
+ { name = "milvus-lite", marker = "sys_platform != 'win32'" },
+]
+
[[package]]
name = "pyopenssl"
version = "26.3.0"
@@ -5971,23 +5990,23 @@ resolution-markers = [
"python_full_version < '3.11'",
]
dependencies = [
- { name = "alabaster", marker = "python_full_version < '3.11'" },
- { name = "babel", marker = "python_full_version < '3.11'" },
- { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" },
- { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
- { name = "imagesize", marker = "python_full_version < '3.11'" },
- { name = "jinja2", marker = "python_full_version < '3.11'" },
- { name = "packaging", marker = "python_full_version < '3.11'" },
- { name = "pygments", marker = "python_full_version < '3.11'" },
- { name = "requests", marker = "python_full_version < '3.11'" },
- { name = "snowballstemmer", marker = "python_full_version < '3.11'" },
- { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.11'" },
- { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.11'" },
- { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.11'" },
- { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.11'" },
- { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.11'" },
- { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.11'" },
- { name = "tomli", marker = "python_full_version < '3.11'" },
+ { name = "alabaster" },
+ { name = "babel" },
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" } },
+ { name = "imagesize" },
+ { name = "jinja2" },
+ { name = "packaging" },
+ { name = "pygments" },
+ { name = "requests" },
+ { name = "snowballstemmer" },
+ { name = "sphinxcontrib-applehelp" },
+ { name = "sphinxcontrib-devhelp" },
+ { name = "sphinxcontrib-htmlhelp" },
+ { name = "sphinxcontrib-jsmath" },
+ { name = "sphinxcontrib-qthelp" },
+ { name = "sphinxcontrib-serializinghtml" },
+ { name = "tomli" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" }
wheels = [
@@ -6004,23 +6023,23 @@ resolution-markers = [
"python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
]
dependencies = [
- { name = "alabaster", marker = "python_full_version == '3.11.*'" },
- { name = "babel", marker = "python_full_version == '3.11.*'" },
- { name = "colorama", marker = "python_full_version == '3.11.*' and sys_platform == 'win32'" },
- { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
- { name = "imagesize", marker = "python_full_version == '3.11.*'" },
- { name = "jinja2", marker = "python_full_version == '3.11.*'" },
- { name = "packaging", marker = "python_full_version == '3.11.*'" },
- { name = "pygments", marker = "python_full_version == '3.11.*'" },
- { name = "requests", marker = "python_full_version == '3.11.*'" },
- { name = "roman-numerals", marker = "python_full_version == '3.11.*'" },
- { name = "snowballstemmer", marker = "python_full_version == '3.11.*'" },
- { name = "sphinxcontrib-applehelp", marker = "python_full_version == '3.11.*'" },
- { name = "sphinxcontrib-devhelp", marker = "python_full_version == '3.11.*'" },
- { name = "sphinxcontrib-htmlhelp", marker = "python_full_version == '3.11.*'" },
- { name = "sphinxcontrib-jsmath", marker = "python_full_version == '3.11.*'" },
- { name = "sphinxcontrib-qthelp", marker = "python_full_version == '3.11.*'" },
- { name = "sphinxcontrib-serializinghtml", marker = "python_full_version == '3.11.*'" },
+ { name = "alabaster" },
+ { name = "babel" },
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" } },
+ { name = "imagesize" },
+ { name = "jinja2" },
+ { name = "packaging" },
+ { name = "pygments" },
+ { name = "requests" },
+ { name = "roman-numerals" },
+ { name = "snowballstemmer" },
+ { name = "sphinxcontrib-applehelp" },
+ { name = "sphinxcontrib-devhelp" },
+ { name = "sphinxcontrib-htmlhelp" },
+ { name = "sphinxcontrib-jsmath" },
+ { name = "sphinxcontrib-qthelp" },
+ { name = "sphinxcontrib-serializinghtml" },
]
sdist = { url = "https://files.pythonhosted.org/packages/42/50/a8c6ccc36d5eacdfd7913ddccd15a9cee03ecafc5ee2bc40e1f168d85022/sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3", size = 8710502, upload-time = "2025-12-04T07:45:27.343Z" }
wheels = [
@@ -6043,23 +6062,23 @@ resolution-markers = [
"python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
]
dependencies = [
- { name = "alabaster", marker = "python_full_version >= '3.12'" },
- { name = "babel", marker = "python_full_version >= '3.12'" },
- { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" },
- { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
- { name = "imagesize", marker = "python_full_version >= '3.12'" },
- { name = "jinja2", marker = "python_full_version >= '3.12'" },
- { name = "packaging", marker = "python_full_version >= '3.12'" },
- { name = "pygments", marker = "python_full_version >= '3.12'" },
- { name = "requests", marker = "python_full_version >= '3.12'" },
- { name = "roman-numerals", marker = "python_full_version >= '3.12'" },
- { name = "snowballstemmer", marker = "python_full_version >= '3.12'" },
- { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.12'" },
- { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.12'" },
- { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.12'" },
- { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.12'" },
- { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.12'" },
- { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.12'" },
+ { name = "alabaster" },
+ { name = "babel" },
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" } },
+ { name = "imagesize" },
+ { name = "jinja2" },
+ { name = "packaging" },
+ { name = "pygments" },
+ { name = "requests" },
+ { name = "roman-numerals" },
+ { name = "snowballstemmer" },
+ { name = "sphinxcontrib-applehelp" },
+ { name = "sphinxcontrib-devhelp" },
+ { name = "sphinxcontrib-htmlhelp" },
+ { name = "sphinxcontrib-jsmath" },
+ { name = "sphinxcontrib-qthelp" },
+ { name = "sphinxcontrib-serializinghtml" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" }
wheels = [