diff --git a/conf/config.yaml b/conf/config.yaml index cfe2da83c..fa78f1d79 100644 --- a/conf/config.yaml +++ b/conf/config.yaml @@ -66,12 +66,16 @@ rdb: default_file_quota: -1 # --- Reranker --- -# Env: RERANKER_ENABLED, RERANKER_MODEL, RERANKER_TOP_K, RERANKER_BASE_URL, RERANKER_PORT +# Env: RERANKER_PROVIDER, RERANKER_ENABLED, RERANKER_MODEL, RERANKER_TOP_K, RERANKER_BASE_URL, RERANKER_API_KEY, RERANKER_TIMEOUT, RERANKER_SEMAPHORE reranker: - enable: true + provider: infinity + enabled: true model_name: Alibaba-NLP/gte-multilingual-reranker-base top_k: 10 - base_url: "" # Default built from RERANKER_PORT if empty + api_key: EMPTY + timeout: 60.0 + semaphore: 5 + base_url: "" # Provider-specific default: infinity → http://reranker:7997, openai → http://reranker:8000/v1 # --- Map-Reduce --- # Env: MAP_REDUCE_INITIAL_BATCH_SIZE, MAP_REDUCE_EXPANSION_BATCH_SIZE, diff --git a/docker-compose.yaml b/docker-compose.yaml index 42a09354b..dd656a594 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,7 +1,7 @@ include: - vdb/milvus.yaml - ${CHAINLIT_DATALAYER_COMPOSE:-extern/dummy.yaml} - - extern/infinity.yaml + - extern/reranker/${RERANKER_PROVIDER:-infinity}.yaml - ${TRANSCRIBER_COMPOSE:-extern/dummy.yaml} x-openrag: &openrag_template diff --git a/docs/content/docs/documentation/env_vars.md b/docs/content/docs/documentation/env_vars.md index 32534c3b1..87d63c42e 100644 --- a/docs/content/docs/documentation/env_vars.md +++ b/docs/content/docs/documentation/env_vars.md @@ -229,19 +229,24 @@ The retriever fetches relevant documents from the vector database based on query ### Reranker Configuration -The reranker enhances search quality by re-scoring and reordering retrieved documents according to their relevance to the user's query. Currently, the system uses [Infinity server](https://github.com/michaelfeil/infinity) for reranking functionality. - -:::info[Future Improvements] -The current Infinity server interface is not OpenAI-compatible, which limits integration flexibility. We plan to improve this by supporting OpenAI-compatible reranker interfaces in future releases. -::: +The reranker enhances search quality by re-scoring and reordering retrieved documents according to their relevance to the user's query. Two providers are supported: **Infinity** (default) and **OpenAI-compatible** endpoints. | Variable | Type | Default | Description | |----------|------|---------|-------------| | `RERANKER_ENABLED` | `bool` | true | Enable or disable the reranking mechanism | +| `RERANKER_PROVIDER` | `str` | `infinity` | Reranker backend to use. Accepted values: `infinity`, `openai` | | `RERANKER_MODEL` | `str` | Alibaba-NLP/gte-multilingual-reranker-base | Model used for reranking documents.| -| `RERANKER_TOP_K` | `int` | 5 | Number of top documents to return after reranking. Increase to 8 for better results if your LLM has a wider context window | +| `RERANKER_TOP_K` | `int` | 10 | Number of top documents to return after reranking. Increase for better results if your LLM has a wider context window | | `RERANKER_BASE_URL` | `str` | `http://reranker:7997` | Base URL of the reranker service | -| `RERANKER_PORT` | `int` | 7997 | Port on which the reranker service listens | +| `RERANKER_API_KEY` | `str` | `EMPTY` | API key for the reranker service. Required when using the `openai` provider | +| `RERANKER_SEMAPHORE` | `int` | 5 | Maximum number of concurrent reranking requests. Adjust based on your server capacity | + +#### Reranker Providers + +| Provider | `RERANKER_PROVIDER` value | Description | +|----------|--------------------------|-------------| +| **Infinity** | `infinity` | Uses the [Infinity server](https://github.com/michaelfeil/infinity) via its native client. Default port: `7997` | +| **OpenAI-compatible** | `openai` | Uses any OpenAI-compatible reranker endpoint (e.g. vLLM, LiteLLM, TEI). Default port: `8000` | ## Extra ### Prompts diff --git a/extern/infinity.yaml b/extern/reranker/infinity.yaml similarity index 83% rename from extern/infinity.yaml rename to extern/reranker/infinity.yaml index ba641a681..e9efc13d5 100644 --- a/extern/infinity.yaml +++ b/extern/reranker/infinity.yaml @@ -6,7 +6,7 @@ x-reranker: &reranker_template volumes: - ${VLLM_CACHE:-/root/.cache/huggingface}:/app/.cache/huggingface # Model weights for RAG # ports: - # - ${RERANKER_PORT:-7997}:${RERANKER_PORT:-7997} + # - ${RERANKER_PORT:-7997}:7997 services: reranker: @@ -23,7 +23,8 @@ services: command: > v2 --model-id ${RERANKER_MODEL:-Alibaba-NLP/gte-multilingual-reranker-base} - --port ${RERANKER_PORT:-7997} + --api-key ${RERANKER_API_KEY:-"EMPTY"} + --port 7997 profiles: - '' @@ -35,7 +36,8 @@ services: v2 --engine torch --model-id ${RERANKER_MODEL:-Alibaba-NLP/gte-multilingual-reranker-base} - --port ${RERANKER_PORT:-7997} + --api-key ${RERANKER_API_KEY:-"EMPTY"} + --port 7997 profiles: - 'cpu' diff --git a/extern/reranker/openai.yaml b/extern/reranker/openai.yaml new file mode 100644 index 000000000..e4b911a79 --- /dev/null +++ b/extern/reranker/openai.yaml @@ -0,0 +1,63 @@ +x-vllm-env: &vllm_env + HUGGING_FACE_HUB_TOKEN: + VLLM_SLEEP_WHEN_IDLE: 1 # Avoid 100% CPU usage when idle + +x-reranker: &reranker_template + networks: + default: + aliases: + - reranker + # restart: on-failure + environment: + - HUGGING_FACE_HUB_TOKEN + - VLLM_SLEEP_WHEN_IDLE=1 # Avoid 100% CPU usage when idle + ipc: "host" + volumes: + - ${VLLM_CACHE:-/root/.cache/huggingface}:/root/.cache/huggingface + command: > + --model ${RERANKER_MODEL:-BAAI/bge-reranker-v2-m3} + --trust-remote-code + --api-key ${RERANKER_API_KEY:-"EMPTY"} + --gpu_memory_utilization 0.3 + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 20s + timeout: 5s + retries: 4 + start_period: 90s + # ports: + # - ${RERANKER_PORT:-8000}:8000 + +services: + reranker-gpu: + <<: *reranker_template + image: vllm/vllm-openai:v0.17.1 + environment: + <<: *vllm_env + NVIDIA_VISIBLE_DEVICES: all + NVIDIA_DRIVER_CAPABILITIES: compute,utility + runtime: nvidia + profiles: + - "" + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] + + reranker-cpu: + <<: *reranker_template + image: vllm/vllm-openai-cpu:v0.17.1 + deploy: {} + environment: + <<: *vllm_env + VLLM_CPU_KVCACHE_SPACE: 8 + command: > + --model ${RERANKER_MODEL:-BAAI/bge-reranker-v2-m3} + --trust-remote-code + --api-key ${RERANKER_API_KEY:-"EMPTY"} + --dtype float32 + profiles: + - "cpu" diff --git a/openrag/app_front.py b/openrag/app_front.py index 39da5bc54..d3210e045 100644 --- a/openrag/app_front.py +++ b/openrag/app_front.py @@ -125,6 +125,7 @@ async def chat_profile(current_user: cl.User): name=m.id, markdown_description=description_template.format(name=m.id, partition=partition), icon="/public/favicon.svg", + default=m.id == f"{PARTITION_PREFIX}all", ) ) return chat_profiles diff --git a/openrag/components/pipeline.py b/openrag/components/pipeline.py index 67fec45c6..7745f73a1 100644 --- a/openrag/components/pipeline.py +++ b/openrag/components/pipeline.py @@ -20,7 +20,7 @@ from .llm import LLM from .map_reduce import RAGMapReduce -from .reranker import Reranker +from .reranker import BaseReranker, RerankerFactory from .retriever import BaseRetriever, RetrieverFactory from .utils import SOURCE_SEPARATOR @@ -49,9 +49,9 @@ def __init__(self) -> None: self.retriever: BaseRetriever = RetrieverFactory.create_retriever(config=config) # reranker - self.reranker_enabled = config.reranker.enable - self.reranker = Reranker(logger, config) - logger.debug("Reranker", enabled=self.reranker_enabled) + self.reranker_enabled = config.reranker.enabled + self.reranker: BaseReranker = RerankerFactory.get_reranker(config) + logger.debug("Reranker", enabled=self.reranker_enabled, provider=config.reranker.provider) self.reranker_top_k = config.reranker.top_k async def retrieve_docs( diff --git a/openrag/components/reranker/__init__.py b/openrag/components/reranker/__init__.py new file mode 100644 index 000000000..aa7e719ac --- /dev/null +++ b/openrag/components/reranker/__init__.py @@ -0,0 +1,17 @@ +from .base import BaseReranker + + +class RerankerFactory: + @staticmethod + def get_reranker(config) -> BaseReranker: + provider = config.reranker.provider + if provider == "infinity": + from .infinity import InfinityReranker + + return InfinityReranker(config) + elif provider == "openai": + from .openai import OpenAIReranker + + return OpenAIReranker(config) + else: + raise ValueError(f"Unsupported reranker provider: {provider}") diff --git a/openrag/components/reranker/base.py b/openrag/components/reranker/base.py new file mode 100644 index 000000000..4a0c2367e --- /dev/null +++ b/openrag/components/reranker/base.py @@ -0,0 +1,40 @@ +from abc import ABC, abstractmethod + +from langchain_core.documents.base import Document + + +class BaseReranker(ABC): + @abstractmethod + async def rerank(self, query: str, documents: list[Document], top_k: int | None = None) -> list[Document]: + """Rerank a list of documents based on a query and an optional top_k parameter""" + + @staticmethod + def rrf_reranking(doc_lists: list[list[Document]], k: int = 60) -> list[Document]: + """Reciprocal_rank_fusion that takes multiple lists of ranked documents + and an optional parameter k used in the RRF formula + RRF formula: \\sum_{i=1}^{n} \frac{1}{k + rank_i} + where rank_i is the rank of the document in the i-th list and n is the number of lists. + + k small: High sensitivity to top ranks + k large: More balanced sensitivity across ranks + k = 60 a common and balanced choice in practice. + """ + + if len(doc_lists) == 1: + return doc_lists[0] + + # Initialize a dictionary to hold fused scores for each unique document + fused_scores = {} + + for doc_list in doc_lists: + doc_list: list[Document] + for rank, doc in enumerate(doc_list, start=1): + doc_id = doc.metadata.get("_id") + doc_key = ("id", doc_id) if doc_id is not None else ("object", id(doc)) + + score, d = fused_scores.get(doc_key, (0, doc)) + fused_scores[doc_key] = (score + 1 / (rank + k), d) + + # sort the docs + reranked_docs = [doc for _, doc in sorted(fused_scores.values(), key=lambda x: x[0], reverse=True)] + return reranked_docs diff --git a/openrag/components/reranker/infinity.py b/openrag/components/reranker/infinity.py new file mode 100644 index 000000000..f55cda05a --- /dev/null +++ b/openrag/components/reranker/infinity.py @@ -0,0 +1,55 @@ +import asyncio + +from infinity_client import Client +from infinity_client.api.default import rerank +from infinity_client.models import RerankInput, ReRankResult +from langchain_core.documents.base import Document +from utils.logger import get_logger + +from .base import BaseReranker + +logger = get_logger() + + +class InfinityReranker(BaseReranker): + def __init__(self, config): + self.model_name = config.reranker.model_name + self.client = Client( + base_url=config.reranker.base_url, + timeout=config.reranker.timeout, + headers={"Authorization": f"Bearer {config.reranker.api_key}"}, + ) + self.semaphore = asyncio.Semaphore(config.reranker.semaphore) + logger.debug("Reranker initialized", model_name=self.model_name) + + async def rerank(self, query: str, documents: list[Document], top_k: int | None = None) -> list[Document]: + async with self.semaphore: + logger.debug("Reranking documents", documents_count=len(documents), top_k=top_k) + top_k = min(top_k, len(documents)) if top_k is not None else len(documents) + rerank_input = RerankInput.from_dict( + { + "model": self.model_name, + "query": query, + "documents": [doc.page_content for doc in documents], + "top_n": top_k, + "return_documents": True, + "raw_scores": True, # Normalized score between 0 and 1 + } + ) + try: + rerank_result: ReRankResult = await rerank.asyncio(client=self.client, body=rerank_input) + output = [] + for rerank_res in rerank_result.results: + doc = documents[rerank_res.index] + doc.metadata["relevance_score"] = rerank_res.relevance_score + output.append(doc) + return output + + except Exception as e: + logger.error( + "Reranking failed", + error=str(e), + model_name=self.model_name, + documents_count=len(documents), + ) + return documents[:top_k] diff --git a/openrag/components/reranker/openai.py b/openrag/components/reranker/openai.py new file mode 100644 index 000000000..43f9d0acf --- /dev/null +++ b/openrag/components/reranker/openai.py @@ -0,0 +1,56 @@ +import asyncio + +import httpx +from langchain_core.documents.base import Document +from utils.logger import get_logger + +from .base import BaseReranker + +logger = get_logger() + + +class OpenAIReranker(BaseReranker): + def __init__(self, config): + self.model_name = config.reranker.model_name + base_url = config.reranker.base_url.rstrip("/") + self.rerank_url = f"{base_url}/rerank" + self.semaphore = asyncio.Semaphore(config.reranker.semaphore) + self.timeout = config.reranker.timeout + self.client = httpx.AsyncClient( + headers={"Authorization": f"Bearer {config.reranker.api_key}"}, + ) + logger.debug("OpenAI Reranker initialized", model_name=self.model_name) + + async def rerank(self, query: str, documents: list[Document], top_k: int | None = None) -> list[Document]: + async with self.semaphore: + logger.debug("Reranking documents", documents_count=len(documents), top_k=top_k) + top_k = min(top_k, len(documents)) if top_k is not None else len(documents) + try: + response = await self.client.post( + self.rerank_url, + json={ + "model": self.model_name, + "query": query, + "documents": [doc.page_content for doc in documents], + "top_n": top_k, + }, + timeout=self.timeout, + ) + response.raise_for_status() + data = response.json() + + output = [] + for result in data["results"]: + doc = documents[result["index"]] + doc.metadata["relevance_score"] = result["relevance_score"] + output.append(doc) + return output + + except Exception as e: + logger.error( + "Reranking failed", + error=str(e), + model_name=self.model_name, + documents_count=len(documents), + ) + return documents[:top_k] diff --git a/openrag/components/test_rrf_reranking.py b/openrag/components/reranker/test_rrf_reranking.py similarity index 98% rename from openrag/components/test_rrf_reranking.py rename to openrag/components/reranker/test_rrf_reranking.py index 5232aa17e..13f098baa 100644 --- a/openrag/components/test_rrf_reranking.py +++ b/openrag/components/reranker/test_rrf_reranking.py @@ -1,8 +1,9 @@ """Tests for BaseReranker.rrf_reranking static method.""" -from components.reranker import BaseReranker from langchain_core.documents.base import Document +from .base import BaseReranker + def make_doc(doc_id: str, content: str = "", **metadata) -> Document: return Document(page_content=content, metadata={"_id": doc_id, **metadata}) diff --git a/openrag/config/loader.py b/openrag/config/loader.py index 60b6f4a8f..759311f5b 100644 --- a/openrag/config/loader.py +++ b/openrag/config/loader.py @@ -53,10 +53,14 @@ ("POSTGRES_PASSWORD", "rdb.password", str), ("DEFAULT_FILE_QUOTA", "rdb.default_file_quota", int), # Reranker - ("RERANKER_ENABLED", "reranker.enable", bool), + ("RERANKER_PROVIDER", "reranker.provider", str), + ("RERANKER_ENABLED", "reranker.enabled", bool), ("RERANKER_MODEL", "reranker.model_name", str), ("RERANKER_TOP_K", "reranker.top_k", int), ("RERANKER_BASE_URL", "reranker.base_url", str), + ("RERANKER_API_KEY", "reranker.api_key", str), + ("RERANKER_TIMEOUT", "reranker.timeout", float), + ("RERANKER_SEMAPHORE", "reranker.semaphore", int), # Map-Reduce ("MAP_REDUCE_INITIAL_BATCH_SIZE", "map_reduce.initial_batch_size", int), ("MAP_REDUCE_EXPANSION_BATCH_SIZE", "map_reduce.expansion_batch_size", int), @@ -235,13 +239,6 @@ def _apply_env_overrides(data: dict) -> dict: for ext in _AUDIO_EXTENSIONS: file_loaders[ext] = audio_loader - # RERANKER_PORT: build default base_url if RERANKER_BASE_URL not set - if not os.environ.get("RERANKER_BASE_URL"): - port = os.environ.get("RERANKER_PORT", "7997") - reranker = data.setdefault("reranker", {}) - if not reranker.get("base_url"): - reranker["base_url"] = f"http://reranker:{port}" - return data @@ -277,6 +274,11 @@ def load_config( # 2. Apply env var overrides data = _apply_env_overrides(data) + # Strip blank reranker.base_url so the provider-specific Pydantic default applies + reranker = data.get("reranker") + if isinstance(reranker, dict) and not reranker.get("base_url"): + reranker.pop("base_url", None) + # 3. Apply programmatic overrides (tests) if overrides: data = _deep_merge(data, overrides) diff --git a/openrag/config/models.py b/openrag/config/models.py index 50564afce..59eabd7e8 100644 --- a/openrag/config/models.py +++ b/openrag/config/models.py @@ -126,11 +126,33 @@ class RDBConfig(ConfigMixin): # --------------------------------------------------------------------------- # Reranker # --------------------------------------------------------------------------- -class RerankerConfig(ConfigMixin): - enable: bool = True +class _BaseRerankerConfig(ConfigMixin): model_name: str = "Alibaba-NLP/gte-multilingual-reranker-base" top_k: int = 10 - base_url: str = "" + api_key: str = Field(default="EMPTY", repr=False) + timeout: float = 60.0 + semaphore: int = 5 + enabled: bool = True + + +class InfinityRerankerConfig(_BaseRerankerConfig): + provider: Literal["infinity"] = "infinity" + base_url: str = "http://reranker:7997" + + +class OpenAIRerankerConfig(_BaseRerankerConfig): + provider: Literal["openai"] = "openai" + base_url: str = "http://reranker:8000/v1" + + +RerankerConfig = Annotated[ + InfinityRerankerConfig | OpenAIRerankerConfig, + Field(discriminator="provider"), +] + + +def _default_reranker_config() -> InfinityRerankerConfig: + return InfinityRerankerConfig() # --------------------------------------------------------------------------- @@ -462,7 +484,7 @@ class Settings(ConfigMixin): embedder: EmbedderConfig = Field(default_factory=EmbedderConfig) vectordb: VectorDBConfig = Field(default_factory=VectorDBConfig) rdb: RDBConfig = Field(default_factory=RDBConfig) - reranker: RerankerConfig = Field(default_factory=RerankerConfig) + reranker: RerankerConfig = Field(default_factory=_default_reranker_config) map_reduce: MapReduceConfig = Field(default_factory=MapReduceConfig) verbose: VerboseConfig = Field(default_factory=VerboseConfig) server: ServerConfig = Field(default_factory=ServerConfig) diff --git a/quick_start/extern/infinity.yaml b/quick_start/extern/infinity.yaml index ba641a681..e9efc13d5 100644 --- a/quick_start/extern/infinity.yaml +++ b/quick_start/extern/infinity.yaml @@ -6,7 +6,7 @@ x-reranker: &reranker_template volumes: - ${VLLM_CACHE:-/root/.cache/huggingface}:/app/.cache/huggingface # Model weights for RAG # ports: - # - ${RERANKER_PORT:-7997}:${RERANKER_PORT:-7997} + # - ${RERANKER_PORT:-7997}:7997 services: reranker: @@ -23,7 +23,8 @@ services: command: > v2 --model-id ${RERANKER_MODEL:-Alibaba-NLP/gte-multilingual-reranker-base} - --port ${RERANKER_PORT:-7997} + --api-key ${RERANKER_API_KEY:-"EMPTY"} + --port 7997 profiles: - '' @@ -35,7 +36,8 @@ services: v2 --engine torch --model-id ${RERANKER_MODEL:-Alibaba-NLP/gte-multilingual-reranker-base} - --port ${RERANKER_PORT:-7997} + --api-key ${RERANKER_API_KEY:-"EMPTY"} + --port 7997 profiles: - 'cpu' diff --git a/uv.lock b/uv.lock index 7d38d511e..7ad977f49 100644 --- a/uv.lock +++ b/uv.lock @@ -3580,13 +3580,13 @@ wheels = [ [[package]] name = "psycopg2" -version = "2.9.10" +version = "2.9.11" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/62/51/2007ea29e605957a17ac6357115d0c1a1b60c8c984951c19419b3474cdfd/psycopg2-2.9.10.tar.gz", hash = "sha256:12ec0b40b0273f95296233e8750441339298e6a572f7039da5b260e3c8b60e11", size = 385672, upload-time = "2024-10-16T11:24:54.832Z" } +sdist = { url = "https://files.pythonhosted.org/packages/89/8d/9d12bc8677c24dad342ec777529bce705b3e785fa05d85122b5502b9ab55/psycopg2-2.9.11.tar.gz", hash = "sha256:964d31caf728e217c697ff77ea69c2ba0865fa41ec20bb00f0977e62fdcc52e3", size = 379598, upload-time = "2025-10-10T11:14:46.075Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/16/4623fad6076448df21c1a870c93a9774ad8a7b4dd1660223b59082dd8fec/psycopg2-2.9.10-cp312-cp312-win32.whl", hash = "sha256:65a63d7ab0e067e2cdb3cf266de39663203d38d6a8ed97f5ca0cb315c73fe067", size = 1025113, upload-time = "2024-10-16T11:18:40.148Z" }, - { url = "https://files.pythonhosted.org/packages/66/de/baed128ae0fc07460d9399d82e631ea31a1f171c0c4ae18f9808ac6759e3/psycopg2-2.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:4a579d6243da40a7b3182e0430493dbd55950c493d8c68f4eec0b302f6bbf20e", size = 1163951, upload-time = "2024-10-16T11:18:44.377Z" }, - { url = "https://files.pythonhosted.org/packages/ae/49/a6cfc94a9c483b1fa401fbcb23aca7892f60c7269c5ffa2ac408364f80dc/psycopg2-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:91fd603a2155da8d0cfcdbf8ab24a2d54bca72795b90d2a3ed2b6da8d979dee2", size = 2569060, upload-time = "2025-01-04T20:09:15.28Z" }, + { url = "https://files.pythonhosted.org/packages/b5/bf/635fbe5dd10ed200afbbfbe98f8602829252ca1cce81cc48fb25ed8dadc0/psycopg2-2.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:e03e4a6dbe87ff81540b434f2e5dc2bddad10296db5eea7bdc995bf5f4162938", size = 2713969, upload-time = "2025-10-10T11:10:15.946Z" }, + { url = "https://files.pythonhosted.org/packages/88/5a/18c8cb13fc6908dc41a483d2c14d927a7a3f29883748747e8cb625da6587/psycopg2-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:8dc379166b5b7d5ea66dcebf433011dfc51a7bb8a5fc12367fa05668e5fc53c8", size = 2714048, upload-time = "2025-10-10T11:10:19.816Z" }, + { url = "https://files.pythonhosted.org/packages/47/08/737aa39c78d705a7ce58248d00eeba0e9fc36be488f9b672b88736fbb1f7/psycopg2-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:f10a48acba5fe6e312b891f290b4d2ca595fc9a06850fe53320beac353575578", size = 2803738, upload-time = "2025-10-10T11:10:23.196Z" }, ] [[package]]