diff --git a/.env.template b/.env.template index 5c2b19c5..05445df5 100644 --- a/.env.template +++ b/.env.template @@ -307,7 +307,7 @@ LLM_OPENAI_API_KEY=your-api-key-here # ============================================================================= # Vector Store Settings # ============================================================================= -# Vector store type: "pgvector", "turbopuffer", or "lancedb" +# Vector store type: "pgvector", "turbopuffer", "lancedb", or "qdrant" VECTOR_STORE_TYPE=pgvector # Migration flag: set to true when migration from pgvector is complete @@ -330,5 +330,14 @@ VECTOR_STORE_MIGRATED=false # LanceDB-specific settings (local embedded mode) # VECTOR_STORE_LANCEDB_PATH=./lancedb_data +# Qdrant-specific settings +# VECTOR_STORE_QDRANT_URL=http://localhost:6333 +# VECTOR_STORE_QDRANT_API_KEY=your-qdrant-api-key +# VECTOR_STORE_QDRANT_PREFER_GRPC=false +# VECTOR_STORE_QDRANT_GRPC_PORT=6334 +# VECTOR_STORE_QDRANT_HTTPS=false +# VECTOR_STORE_QDRANT_PREFIX= +# VECTOR_STORE_QDRANT_TIMEOUT=30 + # Reconciliation interval for background sync (default: 5 minutes) # VECTOR_STORE_RECONCILIATION_INTERVAL_SECONDS=300 diff --git a/CHANGELOG.md b/CHANGELOG.md index 96a8534f..98cd7de9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [Unreleased] + +### Added + +- Qdrant vector store backend (`VECTOR_STORE_TYPE=qdrant`) as an optional `qdrant` extra (#683) + ## [3.1.1] - 2026-09-02 ### Changed diff --git a/config.toml.example b/config.toml.example index 60d9092a..f3ebf3d3 100644 --- a/config.toml.example +++ b/config.toml.example @@ -279,7 +279,7 @@ DEFAULT_LOCK_TTL_SECONDS = 5 # Vector store settings [vector_store] -# Vector store type: "pgvector", "turbopuffer", or "lancedb" +# Vector store type: "pgvector", "turbopuffer", "lancedb", or "qdrant" TYPE = "pgvector" # Migration flag: set to true when migration from pgvector is complete MIGRATED = false @@ -288,4 +288,11 @@ NAMESPACE = "honcho" # TURBOPUFFER_API_KEY = "your-turbopuffer-api-key" # TURBOPUFFER_REGION = "us-east-1" LANCEDB_PATH = "./lancedb_data" +# QDRANT_URL = "http://localhost:6333" +# QDRANT_API_KEY = "your-qdrant-api-key" +# QDRANT_PREFER_GRPC = false +# QDRANT_GRPC_PORT = 6334 +# QDRANT_HTTPS = false +# QDRANT_PREFIX = "" +# QDRANT_TIMEOUT = 30 RECONCILIATION_INTERVAL_SECONDS = 300 diff --git a/docs/v3/contributing/configuration.mdx b/docs/v3/contributing/configuration.mdx index 3c210e71..605ab48c 100644 --- a/docs/v3/contributing/configuration.mdx +++ b/docs/v3/contributing/configuration.mdx @@ -590,7 +590,7 @@ WEBHOOK_MAX_WORKSPACE_LIMIT=10 ### Vector Store ```bash -VECTOR_STORE_TYPE=pgvector # Options: pgvector, turbopuffer, lancedb +VECTOR_STORE_TYPE=pgvector # Options: pgvector, turbopuffer, lancedb, qdrant VECTOR_STORE_MIGRATED=false VECTOR_STORE_NAMESPACE=honcho # Embedding dim is configured via EMBEDDING_VECTOR_DIMENSIONS — see the @@ -602,6 +602,15 @@ VECTOR_STORE_TURBOPUFFER_REGION=us-east-1 # LanceDB-specific VECTOR_STORE_LANCEDB_PATH=./lancedb_data + +# Qdrant-specific +VECTOR_STORE_QDRANT_URL=http://localhost:6333 +VECTOR_STORE_QDRANT_API_KEY=your-qdrant-api-key # optional +VECTOR_STORE_QDRANT_PREFER_GRPC=false +VECTOR_STORE_QDRANT_GRPC_PORT=6334 +VECTOR_STORE_QDRANT_HTTPS=false # optional, inferred from URL scheme +VECTOR_STORE_QDRANT_PREFIX= # optional, for reverse-proxy path prefix +VECTOR_STORE_QDRANT_TIMEOUT= # optional, request timeout in seconds ``` LanceDB is an optional extra and is not included in the default Docker image. Build with `docker build --build-arg INSTALL_LANCEDB=true .` (or `INSTALL_LANCEDB=true docker compose up -d --build`), or run `uv sync --extra lancedb` for manual setups. Note the extra is unavailable on Intel macOS. diff --git a/pyproject.toml b/pyproject.toml index 7c858e23..33de040c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,9 @@ lancedb = [ "lancedb>=0.25.3; sys_platform != \"darwin\" or platform_machine != \"x86_64\"", "pyarrow>=19.0.0", ] +qdrant = [ + "qdrant-client>=1.18.0", +] [dependency-groups] dev = [ "pytest>=8.2.2", diff --git a/src/config.py b/src/config.py index 80827327..f990302d 100644 --- a/src/config.py +++ b/src/config.py @@ -1428,12 +1428,12 @@ class DreamSettings(HonchoSettings): class VectorStoreSettings(HonchoSettings): - """Settings for vector store (pgvector, Turbopuffer, or LanceDB).""" + """Settings for vector store (pgvector, Turbopuffer, LanceDB or Qdrant).""" model_config = SettingsConfigDict(env_prefix="VECTOR_STORE_", extra="ignore") # pyright: ignore # Vector store type to use - TYPE: Literal["pgvector", "turbopuffer", "lancedb"] = "pgvector" + TYPE: Literal["pgvector", "turbopuffer", "lancedb", "qdrant"] = "pgvector" MIGRATED: bool = False @@ -1459,6 +1459,15 @@ class VectorStoreSettings(HonchoSettings): # LanceDB-specific settings (local embedded mode) LANCEDB_PATH: str = "./lancedb_data" + # Qdrant-specific settings + QDRANT_URL: str = "http://localhost:6333" + QDRANT_API_KEY: str | None = None + QDRANT_PREFER_GRPC: bool = False + QDRANT_GRPC_PORT: int = 6334 + QDRANT_HTTPS: bool | None = None + QDRANT_PREFIX: str | None = None + QDRANT_TIMEOUT: int | None = None + RECONCILIATION_INTERVAL_SECONDS: Annotated[int, Field(default=300, gt=0)] = ( 300 # 5 minutes ) diff --git a/src/startup/embedding_validator.py b/src/startup/embedding_validator.py index a397c9e2..d0584dff 100644 --- a/src/startup/embedding_validator.py +++ b/src/startup/embedding_validator.py @@ -76,7 +76,7 @@ async def validate_embedding_schema( dims = await _introspect_pgvector_dims_with_retry(engine, schema) _assert_pgvector_dims_match(dims, schema=schema, target_dim=target_dim) - if s.VECTOR_STORE.TYPE in ("turbopuffer", "lancedb"): + if s.VECTOR_STORE.TYPE in ("turbopuffer", "lancedb", "qdrant"): await _sample_external_namespaces(engine, target_dim=target_dim) diff --git a/src/vector_store/__init__.py b/src/vector_store/__init__.py index 57a558ed..e5e6df10 100644 --- a/src/vector_store/__init__.py +++ b/src/vector_store/__init__.py @@ -214,6 +214,18 @@ def _create_store_by_type(store_type: str) -> VectorStore: ) from exc return LanceDBVectorStore() + elif store_type == "qdrant": + try: + from src.vector_store.qdrant import QdrantVectorStore + except ImportError as exc: + raise RuntimeError( + "VECTOR_STORE.TYPE is set to 'qdrant', but the 'qdrant-client' " + + "package could not be imported. Install Honcho's 'qdrant' extra " + + "(for example, `uv sync --extra qdrant`)" + + f"Original import error: {exc}" + ) from exc + + return QdrantVectorStore() else: raise ValueError(f"Unknown vector store type: {store_type}") diff --git a/src/vector_store/qdrant.py b/src/vector_store/qdrant.py new file mode 100644 index 00000000..56274002 --- /dev/null +++ b/src/vector_store/qdrant.py @@ -0,0 +1,167 @@ +"""Qdrant vector store implementation.""" + +import logging +import uuid +from typing import Any + +from qdrant_client import AsyncQdrantClient, models + +from src.config import settings +from src.exceptions import VectorStoreError + +from . import VectorQueryResult, VectorRecord, VectorStore + +logger = logging.getLogger(__name__) + + +# Qdrant only allows UUIDs and +ve integers as point IDs. +# Ref: https://qdrant.tech/documentation/manage-data/points/#point-ids +# So we convert arbitrary strings to deterministic UUIDs. +def _point_id(string_id: str) -> str: + return str(uuid.uuid5(uuid.NAMESPACE_DNS, string_id)) + + +class QdrantVectorStore(VectorStore): + """Qdrant implementation of VectorStore. Each namespace maps to a collection.""" + + _client: AsyncQdrantClient + _vector_size: int + + def __init__(self) -> None: + super().__init__() + self._client = AsyncQdrantClient( + url=settings.VECTOR_STORE.QDRANT_URL, + api_key=settings.VECTOR_STORE.QDRANT_API_KEY, + prefer_grpc=settings.VECTOR_STORE.QDRANT_PREFER_GRPC, + grpc_port=settings.VECTOR_STORE.QDRANT_GRPC_PORT, + https=settings.VECTOR_STORE.QDRANT_HTTPS, + prefix=settings.VECTOR_STORE.QDRANT_PREFIX, + timeout=settings.VECTOR_STORE.QDRANT_TIMEOUT, + ) + self._vector_size = settings.VECTOR_STORE.DIMENSIONS + + async def _ensure_collection(self, name: str) -> None: + if not await self._client.collection_exists(name): + await self._client.create_collection( + collection_name=name, + vectors_config=models.VectorParams( + size=self._vector_size, + distance=models.Distance.COSINE, + ), + ) + + def _build_filter(self, filters: dict[str, Any]) -> models.Filter | None: + conditions: list[models.Condition] = [] + for k, v in filters.items(): + if isinstance(v, dict) and "in" in v: + conditions.append( + models.FieldCondition(key=k, match=models.MatchAny(any=v["in"])) # pyright: ignore[reportUnknownArgumentType] + ) + elif isinstance(v, list): + conditions.append( + models.FieldCondition(key=k, match=models.MatchAny(any=v)) # pyright: ignore[reportUnknownArgumentType] + ) + else: + conditions.append( + models.FieldCondition(key=k, match=models.MatchValue(value=v)) # pyright: ignore[reportArgumentType] + ) + return models.Filter(must=conditions) if conditions else None + + async def upsert_many(self, namespace: str, vectors: list[VectorRecord]) -> None: + if not vectors: + return + await self._ensure_collection(namespace) + points = [ + models.PointStruct( + id=_point_id(v.id), + vector=v.embedding, + payload={**v.metadata, "_id": v.id}, + ) + for v in vectors + ] + try: + await self._client.upsert(collection_name=namespace, points=points) + except Exception as e: + logger.exception( + f"Failed to upsert {len(vectors)} vectors to namespace {namespace}" + ) + raise VectorStoreError( + f"Qdrant upsert failed for namespace {namespace}" + ) from e + + async def query( + self, + namespace: str, + embedding: list[float], + *, + top_k: int = 10, + filters: dict[str, Any] | None = None, + max_distance: float | None = None, + include_attributes: bool | list[str] = True, + ) -> list[VectorQueryResult]: + if not await self._client.collection_exists(namespace): + return [] + + if include_attributes is False: + with_payload: bool | list[str] = ["_id"] + elif isinstance(include_attributes, list): + with_payload = ["_id", *include_attributes] + else: + with_payload = True + + response = await self._client.query_points( + collection_name=namespace, + query=embedding, + limit=top_k, + query_filter=self._build_filter(filters) if filters else None, + with_payload=with_payload, + ) + + results: list[VectorQueryResult] = [] + for hit in response.points: + dist = 1.0 - float(hit.score) + if max_distance is not None and dist > max_distance: + continue + payload = dict(hit.payload or {}) + results.append( + VectorQueryResult( + id=payload.pop("_id", str(hit.id)), + score=dist, + metadata=payload, + ) + ) + return results + + async def delete_many(self, namespace: str, ids: list[str]) -> None: + if not ids: + return + if not await self._client.collection_exists(namespace): + return + await self._client.delete( + collection_name=namespace, + points_selector=[_point_id(i) for i in ids], + ) + + async def delete_namespace(self, namespace: str) -> None: + if await self._client.collection_exists(namespace): + await self._client.delete_collection(namespace) + + async def close(self) -> None: + await self._client.close() + + async def probe_namespace_dim(self, namespace: str) -> int | None: + if not await self._client.collection_exists(namespace): + return None + + vectors = (await self._client.get_collection(namespace)).config.params.vectors + if vectors is None: + raise VectorStoreError( + f"Qdrant collection {namespace!r} has no vector configuration" + ) + if isinstance(vectors, dict): + if not vectors: + raise VectorStoreError( + f"Qdrant collection {namespace!r} has empty named-vector configuration" + ) + vectors = next(iter(vectors.values())) + return int(vectors.size) diff --git a/tests/vector_store/test_qdrant.py b/tests/vector_store/test_qdrant.py new file mode 100644 index 00000000..85462503 --- /dev/null +++ b/tests/vector_store/test_qdrant.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +import uuid +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest +from qdrant_client import models + +from src.exceptions import VectorStoreError +from src.vector_store.qdrant import ( + QdrantVectorStore, + _point_id, # pyright: ignore[reportPrivateUsage] +) + + +@pytest.fixture +def store(monkeypatch: pytest.MonkeyPatch) -> QdrantVectorStore: + monkeypatch.setattr("src.vector_store.qdrant.AsyncQdrantClient", MagicMock()) + return QdrantVectorStore() + + +def _mock_client(store: QdrantVectorStore) -> MagicMock: + client = MagicMock() + client.collection_exists = AsyncMock(return_value=True) + client.query_points = AsyncMock(return_value=SimpleNamespace(points=[])) + client.get_collection = AsyncMock() + store._client = client # pyright: ignore[reportPrivateUsage] + return client + + +def _hit(*, id: str, score: float, payload: dict[str, Any]) -> SimpleNamespace: + return SimpleNamespace(id=id, score=score, payload=payload) + + +def test_point_id_is_deterministic_and_a_valid_uuid() -> None: + assert _point_id("user_123") == _point_id("user_123") + assert _point_id("user_123") != _point_id("user_456") + uuid.UUID(_point_id("user_123")) + + +def test_build_filter_membership(store: QdrantVectorStore) -> None: + f = store._build_filter({"session_name": {"in": ["s1", "s2"]}}) # pyright: ignore[reportPrivateUsage] + assert f is not None and f.must is not None + + +@pytest.mark.asyncio +async def test_query_returns_empty_when_collection_missing( + store: QdrantVectorStore, +) -> None: + client = _mock_client(store) + client.collection_exists = AsyncMock(return_value=False) + + results = await store.query("honcho.msg.missing", [0.1, 0.2, 0.3, 0.4]) + + assert results == [] + client.query_points.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_query_include_attributes_false_still_recovers_id( + store: QdrantVectorStore, +) -> None: + client = _mock_client(store) + + await store.query("honcho.msg.test", [0.1, 0.2, 0.3, 0.4], include_attributes=False) + + assert client.query_points.await_args.kwargs["with_payload"] == ["_id"] + + +@pytest.mark.asyncio +async def test_query_attribute_list_projects_id_plus_listed( + store: QdrantVectorStore, +) -> None: + client = _mock_client(store) + + await store.query( + "honcho.msg.test", + [0.1, 0.2, 0.3, 0.4], + include_attributes=["message_id"], + ) + + assert client.query_points.await_args.kwargs["with_payload"] == [ + "_id", + "message_id", + ] + + +@pytest.mark.asyncio +async def test_query_converts_hits_to_results_with_distance_and_metadata( + store: QdrantVectorStore, +) -> None: + client = _mock_client(store) + client.query_points = AsyncMock( + return_value=SimpleNamespace( + points=[ + _hit( + id="", + score=0.88, + payload={"_id": "vec_1", "message_id": "msg_1"}, + ), + _hit(id="", score=0.66, payload={"_id": "vec_2"}), + ] + ) + ) + + results = await store.query("honcho.msg.test", [0.1, 0.2, 0.3, 0.4]) + + assert [r.id for r in results] == ["vec_1", "vec_2"] + assert results[0].score == 1.0 - 0.88 + assert results[1].score == 1.0 - 0.66 + assert results[0].metadata == {"message_id": "msg_1"} + assert results[1].metadata == {} + + +@pytest.mark.asyncio +async def test_query_filters_by_max_distance(store: QdrantVectorStore) -> None: + client = _mock_client(store) + client.query_points = AsyncMock( + return_value=SimpleNamespace( + points=[ + _hit(id="", score=0.95, payload={"_id": "vec_close"}), + _hit(id="", score=0.1, payload={"_id": "vec_far"}), + ] + ) + ) + + results = await store.query( + "honcho.msg.test", + [0.1, 0.2, 0.3, 0.4], + max_distance=0.5, + ) + + assert [r.id for r in results] == ["vec_close"] + + +@pytest.mark.asyncio +async def test_probe_returns_none_for_missing_collection( + store: QdrantVectorStore, +) -> None: + client = _mock_client(store) + client.collection_exists = AsyncMock(return_value=False) + + assert await store.probe_namespace_dim("does_not_exist") is None + + +@pytest.mark.asyncio +async def test_probe_returns_declared_dim(store: QdrantVectorStore) -> None: + client = _mock_client(store) + client.get_collection = AsyncMock( + return_value=SimpleNamespace( + config=SimpleNamespace( + params=SimpleNamespace( + vectors=models.VectorParams( + size=768, distance=models.Distance.COSINE + ) + ) + ) + ) + ) + + assert await store.probe_namespace_dim("probe_test") == 768 + + +@pytest.mark.asyncio +async def test_probe_raises_when_vector_config_missing( + store: QdrantVectorStore, +) -> None: + client = _mock_client(store) + client.get_collection = AsyncMock( + return_value=SimpleNamespace( + config=SimpleNamespace(params=SimpleNamespace(vectors=None)) + ) + ) + + with pytest.raises(VectorStoreError): + await store.probe_namespace_dim("corrupt") diff --git a/uv.lock b/uv.lock index a9a92a6d..465f2afe 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,10 @@ version = 1 revision = 3 requires-python = ">=3.13" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version < '3.14'", +] [options] exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. @@ -813,6 +817,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/32/77ee8a6c1564fc345a491a4e85b3bf360e4cf26eac98c4532d2fdb96e01f/greenlet-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86", size = 245324, upload-time = "2026-04-27T12:24:40.295Z" }, ] +[[package]] +name = "grpcio" +version = "1.80.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/48/af6173dbca4454f4637a4678b67f52ca7e0c1ed7d5894d89d434fecede05/grpcio-1.80.0.tar.gz", hash = "sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257", size = 12978905, upload-time = "2026-03-30T08:49:10.502Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/3a/7c3c25789e3f069e581dc342e03613c5b1cb012c4e8c7d9d5cf960a75856/grpcio-1.80.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:e9e408fc016dffd20661f0126c53d8a31c2821b5c13c5d67a0f5ed5de93319ad", size = 6017243, upload-time = "2026-03-30T08:47:40.075Z" }, + { url = "https://files.pythonhosted.org/packages/04/19/21a9806eb8240e174fd1ab0cd5b9aa948bb0e05c2f2f55f9d5d7405e6d08/grpcio-1.80.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:92d787312e613754d4d8b9ca6d3297e69994a7912a32fa38c4c4e01c272974b0", size = 12010840, upload-time = "2026-03-30T08:47:43.11Z" }, + { url = "https://files.pythonhosted.org/packages/18/3a/23347d35f76f639e807fb7a36fad3068aed100996849a33809591f26eca6/grpcio-1.80.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac393b58aa16991a2f1144ec578084d544038c12242da3a215966b512904d0f", size = 6567644, upload-time = "2026-03-30T08:47:46.806Z" }, + { url = "https://files.pythonhosted.org/packages/ff/40/96e07ecb604a6a67ae6ab151e3e35b132875d98bc68ec65f3e5ab3e781d7/grpcio-1.80.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:68e5851ac4b9afe07e7f84483803ad167852570d65326b34d54ca560bfa53fb6", size = 7277830, upload-time = "2026-03-30T08:47:49.643Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e2/da1506ecea1f34a5e365964644b35edef53803052b763ca214ba3870c856/grpcio-1.80.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:873ff5d17d68992ef6605330127425d2fc4e77e612fa3c3e0ed4e668685e3140", size = 6783216, upload-time = "2026-03-30T08:47:52.817Z" }, + { url = "https://files.pythonhosted.org/packages/44/83/3b20ff58d0c3b7f6caaa3af9a4174d4023701df40a3f39f7f1c8e7c48f9d/grpcio-1.80.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2bea16af2750fd0a899bf1abd9022244418b55d1f37da2202249ba4ba673838d", size = 7385866, upload-time = "2026-03-30T08:47:55.687Z" }, + { url = "https://files.pythonhosted.org/packages/47/45/55c507599c5520416de5eefecc927d6a0d7af55e91cfffb2e410607e5744/grpcio-1.80.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba0db34f7e1d803a878284cd70e4c63cb6ae2510ba51937bf8f45ba997cefcf7", size = 8391602, upload-time = "2026-03-30T08:47:58.303Z" }, + { url = "https://files.pythonhosted.org/packages/10/bb/dd06f4c24c01db9cf11341b547d0a016b2c90ed7dbbb086a5710df7dd1d7/grpcio-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8eb613f02d34721f1acf3626dfdb3545bd3c8505b0e52bf8b5710a28d02e8aa7", size = 7826752, upload-time = "2026-03-30T08:48:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/f9/1e/9d67992ba23371fd63d4527096eb8c6b76d74d52b500df992a3343fd7251/grpcio-1.80.0-cp313-cp313-win32.whl", hash = "sha256:93b6f823810720912fd131f561f91f5fed0fda372b6b7028a2681b8194d5d294", size = 4142310, upload-time = "2026-03-30T08:48:04.594Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e6/283326a27da9e2c3038bc93eeea36fb118ce0b2d03922a9cda6688f53c5b/grpcio-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:e172cf795a3ba5246d3529e4d34c53db70e888fa582a8ffebd2e6e48bc0cba50", size = 4882833, upload-time = "2026-03-30T08:48:07.363Z" }, + { url = "https://files.pythonhosted.org/packages/c5/6d/e65307ce20f5a09244ba9e9d8476e99fb039de7154f37fb85f26978b59c3/grpcio-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3d4147a97c8344d065d01bbf8b6acec2cf86fb0400d40696c8bdad34a64ffc0e", size = 6017376, upload-time = "2026-03-30T08:48:10.005Z" }, + { url = "https://files.pythonhosted.org/packages/69/10/9cef5d9650c72625a699c549940f0abb3c4bfdb5ed45a5ce431f92f31806/grpcio-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8e11f167935b3eb089ac9038e1a063e6d7dbe995c0bb4a661e614583352e76f", size = 12018133, upload-time = "2026-03-30T08:48:12.927Z" }, + { url = "https://files.pythonhosted.org/packages/04/82/983aabaad82ba26113caceeb9091706a0696b25da004fe3defb5b346e15b/grpcio-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f14b618fc30de822681ee986cfdcc2d9327229dc4c98aed16896761cacd468b9", size = 6574748, upload-time = "2026-03-30T08:48:16.386Z" }, + { url = "https://files.pythonhosted.org/packages/07/d7/031666ef155aa0bf399ed7e19439656c38bbd143779ae0861b038ce82abd/grpcio-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4ed39fbdcf9b87370f6e8df4e39ca7b38b3e5e9d1b0013c7b6be9639d6578d14", size = 7277711, upload-time = "2026-03-30T08:48:19.627Z" }, + { url = "https://files.pythonhosted.org/packages/e8/43/f437a78f7f4f1d311804189e8f11fb311a01049b2e08557c1068d470cb2e/grpcio-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2dcc70e9f0ba987526e8e8603a610fb4f460e42899e74e7a518bf3c68fe1bf05", size = 6785372, upload-time = "2026-03-30T08:48:22.373Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/f6558e9c6296cb4227faa5c43c54a34c68d32654b829f53288313d16a86e/grpcio-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448c884b668b868562b1bda833c5fce6272d26e1926ec46747cda05741d302c1", size = 7395268, upload-time = "2026-03-30T08:48:25.638Z" }, + { url = "https://files.pythonhosted.org/packages/06/21/0fdd77e84720b08843c371a2efa6f2e19dbebf56adc72df73d891f5506f0/grpcio-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a1dc80fe55685b4a543555e6eef975303b36c8db1023b1599b094b92aa77965f", size = 8392000, upload-time = "2026-03-30T08:48:28.974Z" }, + { url = "https://files.pythonhosted.org/packages/f5/68/67f4947ed55d2e69f2cc199ab9fd85e0a0034d813bbeef84df6d2ba4d4b7/grpcio-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:31b9ac4ad1aa28ffee5503821fafd09e4da0a261ce1c1281c6c8da0423c83b6e", size = 7828477, upload-time = "2026-03-30T08:48:32.054Z" }, + { url = "https://files.pythonhosted.org/packages/44/b6/8d4096691b2e385e8271911a0de4f35f0a6c7d05aff7098e296c3de86939/grpcio-1.80.0-cp314-cp314-win32.whl", hash = "sha256:367ce30ba67d05e0592470428f0ec1c31714cab9ef19b8f2e37be1f4c7d32fae", size = 4218563, upload-time = "2026-03-30T08:48:34.538Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8c/bbe6baf2557262834f2070cf668515fa308b2d38a4bbf771f8f7872a7036/grpcio-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:3b01e1f5464c583d2f567b2e46ff0d516ef979978f72091fd81f5ab7fa6e2e7f", size = 5019457, upload-time = "2026-03-30T08:48:37.308Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -822,6 +857,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "h2" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, +] + [[package]] name = "honcho" version = "3.1.1" @@ -863,6 +911,9 @@ lancedb = [ { name = "lancedb", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, { name = "pyarrow" }, ] +qdrant = [ + { name = "qdrant-client" }, +] [package.dev-dependencies] dev = [ @@ -906,6 +957,7 @@ requires-dist = [ { name = "pydantic-settings", specifier = ">=2.10.1" }, { name = "pyjwt", specifier = ">=2.10.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "qdrant-client", marker = "extra == 'qdrant'", specifier = ">=1.18.0" }, { name = "redis", specifier = ">=7.0.0,<8.0.0" }, { name = "rich", specifier = ">=13.7.1" }, { name = "scikit-learn", specifier = ">=1.6.0" }, @@ -916,7 +968,7 @@ requires-dist = [ { name = "turbopuffer", specifier = ">=1.8.1" }, { name = "typing-extensions", specifier = ">=4.11.0" }, ] -provides-extras = ["lancedb"] +provides-extras = ["lancedb", "qdrant"] [package.metadata.requires-dev] dev = [ @@ -997,6 +1049,15 @@ requires-dist = [ ] provides-extras = ["dev"] +[[package]] +name = "hpack" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -1047,6 +1108,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[package.optional-dependencies] +http2 = [ + { name = "h2" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + [[package]] name = "identify" version = "2.6.19" @@ -1776,6 +1851,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "portalocker" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/77/65b857a69ed876e1951e88aaba60f5ce6120c33703f7cb61a3c894b8c1b6/portalocker-3.2.0.tar.gz", hash = "sha256:1f3002956a54a8c3730586c5c77bf18fae4149e07eaf1c29fc3faf4d5a3f89ac", size = 95644, upload-time = "2025-06-14T13:20:40.03Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/a6/38c8e2f318bf67d338f4d629e93b0b4b9af331f455f0390ea8ce4a099b26/portalocker-3.2.0-py3-none-any.whl", hash = "sha256:3cdc5f565312224bc570c49337bd21428bba0ef363bbcf58b9ef4a9f11779968", size = 22424, upload-time = "2025-06-14T13:20:38.083Z" }, +] + [[package]] name = "pre-commit" version = "4.6.0" @@ -2378,6 +2465,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/78/4126abcbdbd3c559d43e0db7f7b9173fc6befe45d39a2856cc0b8ec2a5a6/python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645", size = 29254, upload-time = "2026-04-27T10:51:24.997Z" }, ] +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -2414,6 +2514,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "qdrant-client" +version = "1.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "grpcio" }, + { name = "httpx", extra = ["http2"] }, + { name = "numpy" }, + { name = "portalocker" }, + { name = "protobuf" }, + { name = "pydantic" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/65/45/5b1bdd15a3c7730eefb9c113600829e20d689b82b5a23f9e07d107094004/qdrant_client-1.18.0.tar.gz", hash = "sha256:52e8ece1a7d40519801bf0b70713bfa0f6b7ae28c7275bbe0b0286fbed7f6db4", size = 352580, upload-time = "2026-05-11T14:12:38.702Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/10/c437bd2ac41ef30d3019063e6ce537dc111e9214473b337ee88f7fa6359a/qdrant_client-1.18.0-py3-none-any.whl", hash = "sha256:093aa8cf8a420ee3ad2a68b007e1378d7992b2600e0b53c193fc172674f659cd", size = 398126, upload-time = "2026-05-11T14:12:36.998Z" }, +] + [[package]] name = "redis" version = "7.4.0"