xyberos-qdrant 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- xyberos_qdrant-0.1.0/PKG-INFO +68 -0
- xyberos_qdrant-0.1.0/README.md +54 -0
- xyberos_qdrant-0.1.0/pyproject.toml +27 -0
- xyberos_qdrant-0.1.0/setup.cfg +4 -0
- xyberos_qdrant-0.1.0/tests/test_parity.py +49 -0
- xyberos_qdrant-0.1.0/tests/test_plugin.py +25 -0
- xyberos_qdrant-0.1.0/tests/test_vector.py +57 -0
- xyberos_qdrant-0.1.0/xyberos_qdrant/__init__.py +6 -0
- xyberos_qdrant-0.1.0/xyberos_qdrant/plugin.py +65 -0
- xyberos_qdrant-0.1.0/xyberos_qdrant/vector.py +193 -0
- xyberos_qdrant-0.1.0/xyberos_qdrant.egg-info/PKG-INFO +68 -0
- xyberos_qdrant-0.1.0/xyberos_qdrant.egg-info/SOURCES.txt +14 -0
- xyberos_qdrant-0.1.0/xyberos_qdrant.egg-info/dependency_links.txt +1 -0
- xyberos_qdrant-0.1.0/xyberos_qdrant.egg-info/entry_points.txt +2 -0
- xyberos_qdrant-0.1.0/xyberos_qdrant.egg-info/requires.txt +7 -0
- xyberos_qdrant-0.1.0/xyberos_qdrant.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xyberos-qdrant
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Qdrant VectorStore plugin (RFC-0019, M4): hosted or in-memory Qdrant as an xyberos VectorStore
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Keywords: xyberos,plugin,vector,qdrant,rag
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: xyberos>=1.0
|
|
10
|
+
Provides-Extra: vectors
|
|
11
|
+
Requires-Dist: qdrant-client; extra == "vectors"
|
|
12
|
+
Provides-Extra: test
|
|
13
|
+
Requires-Dist: pytest; extra == "test"
|
|
14
|
+
|
|
15
|
+
# xyberos-qdrant
|
|
16
|
+
|
|
17
|
+
**Qdrant `VectorStore` plugin — RFC-0019, M4.** Hosted or local [Qdrant](https://qdrant.tech)
|
|
18
|
+
as an `xyberos` `VectorStore`. Passes the same parity scenarios as the stdlib
|
|
19
|
+
`SqliteVectorStore` (the M4 Definition of Done).
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install -e ./qdrant
|
|
25
|
+
pip install qdrant-client # or: pip install xyberos[vectors]
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from xyberos import create_app
|
|
32
|
+
from xyberos_qdrant import QdrantPlugin
|
|
33
|
+
|
|
34
|
+
app = create_app()
|
|
35
|
+
app.load_plugin(QdrantPlugin(location=":memory:")) # local, no infrastructure
|
|
36
|
+
# hosted: QdrantPlugin(url="https://...", api_key=os.getenv("QDRANT_API_KEY"))
|
|
37
|
+
|
|
38
|
+
store = app.resolve("vector_store")
|
|
39
|
+
store.upsert("ns", "doc-1", [1.0, 0.0, 0.0, 0.0], {"text": "alpha"})
|
|
40
|
+
hits = store.query("ns", [1.0, 0.0, 0.0, 0.0], top_k=3)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Or configure through the environment (`QDRANT_URL`, `QDRANT_API_KEY`,
|
|
44
|
+
`QDRANT_DIM`) and let the `xyberos.plugins` entry point register it.
|
|
45
|
+
|
|
46
|
+
## Design
|
|
47
|
+
|
|
48
|
+
- Each **namespace** maps to a Qdrant collection using **cosine** distance
|
|
49
|
+
(higher score = more relevant, matching the `VectorStore` contract).
|
|
50
|
+
- The client is imported **lazily** and a clear `ProviderError` is raised when
|
|
51
|
+
`qdrant-client` is missing.
|
|
52
|
+
- Arbitrary string ids are mapped to deterministic UUIDs (Qdrant point ids are
|
|
53
|
+
`int`/`UUID`); the original id round-trips through the payload, so the
|
|
54
|
+
contract's string ids hold exactly.
|
|
55
|
+
|
|
56
|
+
## Tests
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install pytest
|
|
60
|
+
pytest tests/ # unit + parity + plugin tests
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Parity tests run against Qdrant's **in-memory local mode**, so they need no
|
|
64
|
+
server; they skip cleanly if `qdrant-client` is absent.
|
|
65
|
+
|
|
66
|
+
## Ship location
|
|
67
|
+
|
|
68
|
+
`[vectors]` extra (`qdrant-client` already added to `pyproject.toml`).
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# xyberos-qdrant
|
|
2
|
+
|
|
3
|
+
**Qdrant `VectorStore` plugin — RFC-0019, M4.** Hosted or local [Qdrant](https://qdrant.tech)
|
|
4
|
+
as an `xyberos` `VectorStore`. Passes the same parity scenarios as the stdlib
|
|
5
|
+
`SqliteVectorStore` (the M4 Definition of Done).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install -e ./qdrant
|
|
11
|
+
pip install qdrant-client # or: pip install xyberos[vectors]
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from xyberos import create_app
|
|
18
|
+
from xyberos_qdrant import QdrantPlugin
|
|
19
|
+
|
|
20
|
+
app = create_app()
|
|
21
|
+
app.load_plugin(QdrantPlugin(location=":memory:")) # local, no infrastructure
|
|
22
|
+
# hosted: QdrantPlugin(url="https://...", api_key=os.getenv("QDRANT_API_KEY"))
|
|
23
|
+
|
|
24
|
+
store = app.resolve("vector_store")
|
|
25
|
+
store.upsert("ns", "doc-1", [1.0, 0.0, 0.0, 0.0], {"text": "alpha"})
|
|
26
|
+
hits = store.query("ns", [1.0, 0.0, 0.0, 0.0], top_k=3)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Or configure through the environment (`QDRANT_URL`, `QDRANT_API_KEY`,
|
|
30
|
+
`QDRANT_DIM`) and let the `xyberos.plugins` entry point register it.
|
|
31
|
+
|
|
32
|
+
## Design
|
|
33
|
+
|
|
34
|
+
- Each **namespace** maps to a Qdrant collection using **cosine** distance
|
|
35
|
+
(higher score = more relevant, matching the `VectorStore` contract).
|
|
36
|
+
- The client is imported **lazily** and a clear `ProviderError` is raised when
|
|
37
|
+
`qdrant-client` is missing.
|
|
38
|
+
- Arbitrary string ids are mapped to deterministic UUIDs (Qdrant point ids are
|
|
39
|
+
`int`/`UUID`); the original id round-trips through the payload, so the
|
|
40
|
+
contract's string ids hold exactly.
|
|
41
|
+
|
|
42
|
+
## Tests
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install pytest
|
|
46
|
+
pytest tests/ # unit + parity + plugin tests
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Parity tests run against Qdrant's **in-memory local mode**, so they need no
|
|
50
|
+
server; they skip cleanly if `qdrant-client` is absent.
|
|
51
|
+
|
|
52
|
+
## Ship location
|
|
53
|
+
|
|
54
|
+
`[vectors]` extra (`qdrant-client` already added to `pyproject.toml`).
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "xyberos-qdrant"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Qdrant VectorStore plugin (RFC-0019, M4): hosted or in-memory Qdrant as an xyberos VectorStore"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = {text = "Apache-2.0"}
|
|
12
|
+
dependencies = ["xyberos>=1.0"]
|
|
13
|
+
keywords = ["xyberos", "plugin", "vector", "qdrant", "rag"]
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
vectors = ["qdrant-client"]
|
|
17
|
+
test = ["pytest"]
|
|
18
|
+
|
|
19
|
+
[project.entry-points."xyberos.plugins"]
|
|
20
|
+
qdrant = "xyberos_qdrant.plugin:plugin"
|
|
21
|
+
|
|
22
|
+
[tool.setuptools]
|
|
23
|
+
packages = ["xyberos_qdrant"]
|
|
24
|
+
|
|
25
|
+
[tool.pytest.ini_options]
|
|
26
|
+
testpaths = ["tests"]
|
|
27
|
+
pythonpath = ["."]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""M4 parity: QdrantVectorStore must behave like SqliteVectorStore."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from xyberos.vector import SqliteVectorStore
|
|
7
|
+
|
|
8
|
+
from xyberos_qdrant import QdrantVectorStore
|
|
9
|
+
|
|
10
|
+
QUERY = [1.0, 0.0, 0.0, 0.0]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run_parity_scenarios(store) -> None:
|
|
14
|
+
"""The full VectorStore contract; every adapter must pass these."""
|
|
15
|
+
store.upsert("ns", "a", [1.0, 0.0, 0.0, 0.0], {"text": "alpha"})
|
|
16
|
+
store.upsert("ns", "b", [0.8, 0.2, 0.0, 0.0], {"text": "beta"})
|
|
17
|
+
store.upsert("ns", "c", [0.5, 0.5, 0.0, 0.0], {"text": "gamma"})
|
|
18
|
+
store.upsert("other", "x", [1.0, 1.0, 1.0, 1.0], {"text": "other ns"})
|
|
19
|
+
|
|
20
|
+
hits = store.query("ns", QUERY, top_k=3)
|
|
21
|
+
assert [h.id for h in hits] == ["a", "b", "c"]
|
|
22
|
+
assert hits[0].payload["text"] == "alpha"
|
|
23
|
+
assert hits[0].score > hits[1].score > hits[2].score
|
|
24
|
+
assert len(store.query("ns", QUERY, top_k=1)) == 1
|
|
25
|
+
|
|
26
|
+
strict = store.query("ns", QUERY, top_k=3, threshold=0.99)
|
|
27
|
+
assert [h.id for h in strict] == ["a"]
|
|
28
|
+
|
|
29
|
+
store.delete("ns", "b")
|
|
30
|
+
assert [h.id for h in store.query("ns", QUERY, top_k=3)] == ["a", "c"]
|
|
31
|
+
assert [h.id for h in store.query("other", [1.0, 1.0, 1.0, 1.0], top_k=2)] == ["x"]
|
|
32
|
+
assert all(abs(h.score) < 1e-6 for h in store.query("ns", [0.0, 0.0, 0.0, 1.0], top_k=2))
|
|
33
|
+
|
|
34
|
+
store.upsert("ns", "a", [1.0, 0.0, 0.0, 0.0], {"text": "alpha-v2"})
|
|
35
|
+
assert store.query("ns", QUERY, top_k=1)[0].payload["text"] == "alpha-v2"
|
|
36
|
+
|
|
37
|
+
store.clear("ns")
|
|
38
|
+
assert store.query("ns", QUERY, top_k=3) == []
|
|
39
|
+
assert [h.id for h in store.query("other", [1.0, 1.0, 1.0, 1.0], top_k=1)] == ["x"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_sqlite_reference() -> None:
|
|
43
|
+
"""The stdlib reference passes the same scenarios the adapters must match."""
|
|
44
|
+
run_parity_scenarios(SqliteVectorStore(":memory:"))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_qdrant_parity():
|
|
48
|
+
pytest.importorskip("qdrant_client")
|
|
49
|
+
run_parity_scenarios(QdrantVectorStore(location=":memory:"))
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Tests for loading the qdrant plugin into a Xyberos app."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from xyberos import create_app
|
|
7
|
+
|
|
8
|
+
from xyberos_qdrant import QdrantPlugin
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_plugin_conforms_to_contract():
|
|
12
|
+
plugin = QdrantPlugin(location=":memory:")
|
|
13
|
+
assert plugin.name == "qdrant"
|
|
14
|
+
assert callable(plugin.register) and callable(plugin.unregister)
|
|
15
|
+
assert plugin.vector_store() is plugin.vector_store()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_plugin_registers_vector_store():
|
|
19
|
+
pytest.importorskip("qdrant_client")
|
|
20
|
+
app = create_app()
|
|
21
|
+
app.load_plugin(QdrantPlugin(location=":memory:"))
|
|
22
|
+
store = app.resolve("vector_store")
|
|
23
|
+
store.upsert("ns", "a", [1.0, 0.0, 0.0, 0.0], {"text": "alpha"})
|
|
24
|
+
assert store.query("ns", [1.0, 0.0, 0.0, 0.0], top_k=1)[0].payload["text"] == "alpha"
|
|
25
|
+
app.unload_plugin("qdrant")
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Unit tests for the QdrantVectorStore (in-memory local mode)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from xyberos_qdrant import QdrantVectorStore
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.fixture()
|
|
11
|
+
def store():
|
|
12
|
+
pytest.importorskip("qdrant_client")
|
|
13
|
+
instance = QdrantVectorStore(location=":memory:")
|
|
14
|
+
return instance
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_upsert_and_query(store):
|
|
18
|
+
store.upsert("ns", "a", [1.0, 0.0, 0.0], {"text": "alpha"})
|
|
19
|
+
store.upsert("ns", "b", [0.0, 1.0, 0.0], {"text": "beta"})
|
|
20
|
+
hits = store.query("ns", [1.0, 0.0, 0.0], top_k=2)
|
|
21
|
+
assert hits[0].id == "a"
|
|
22
|
+
assert hits[0].payload["text"] == "alpha"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_string_ids_round_trip(store):
|
|
26
|
+
store.upsert("ns", "document-1", [1.0, 0.0], {"text": "one"})
|
|
27
|
+
store.upsert("ns", "document-2", [0.0, 1.0], {"text": "two"})
|
|
28
|
+
hits = store.query("ns", [1.0, 0.0], top_k=2)
|
|
29
|
+
assert {h.id for h in hits} == {"document-1", "document-2"}
|
|
30
|
+
assert {h.payload["text"] for h in hits} == {"one", "two"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_delete(store):
|
|
34
|
+
store.upsert("ns", "a", [1.0, 0.0, 0.0, 0.0])
|
|
35
|
+
store.upsert("ns", "b", [0.0, 1.0, 0.0, 0.0])
|
|
36
|
+
store.delete("ns", "a")
|
|
37
|
+
assert [h.id for h in store.query("ns", [1.0, 0.0, 0.0, 0.0], top_k=2)] == ["b"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_clear(store):
|
|
41
|
+
store.upsert("ns", "a", [1.0, 0.0, 0.0, 0.0])
|
|
42
|
+
store.clear("ns")
|
|
43
|
+
assert store.query("ns", [1.0, 0.0, 0.0, 0.0]) == []
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_missing_namespace_returns_empty(store):
|
|
47
|
+
assert store.query("missing", [1.0, 0.0, 0.0, 0.0]) == []
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_provider_error_when_client_missing(monkeypatch):
|
|
51
|
+
import importlib.util
|
|
52
|
+
|
|
53
|
+
if importlib.util.find_spec("qdrant_client"):
|
|
54
|
+
pytest.skip("qdrant-client is installed")
|
|
55
|
+
store = QdrantVectorStore(url="http://localhost:6333")
|
|
56
|
+
with pytest.raises(Exception):
|
|
57
|
+
store.upsert("ns", "a", [1.0, 0.0, 0.0])
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Qdrant plugin entry point (RFC-0019, M4)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from xyberos.contracts import Plugin, VectorStore
|
|
9
|
+
|
|
10
|
+
from .vector import QdrantVectorStore
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class QdrantPlugin(Plugin):
|
|
14
|
+
"""Registers a :class:`QdrantVectorStore` as the app's ``vector_store``.
|
|
15
|
+
|
|
16
|
+
Configuration (in priority order): explicit constructor args, then
|
|
17
|
+
``QDRANT_URL`` / ``QDRANT_API_KEY`` / ``QDRANT_DIM`` env vars. With no
|
|
18
|
+
configuration it defaults to Qdrant's in-memory local mode, which is
|
|
19
|
+
harmless for development and testing.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
*,
|
|
25
|
+
url: str | None = None,
|
|
26
|
+
api_key: str | None = None,
|
|
27
|
+
location: str | None = None,
|
|
28
|
+
path: str | None = None,
|
|
29
|
+
dim: int | None = None,
|
|
30
|
+
client: Any | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
self._url = url or os.getenv("QDRANT_URL")
|
|
33
|
+
self._api_key = api_key or os.getenv("QDRANT_API_KEY")
|
|
34
|
+
self._location = location
|
|
35
|
+
self._path = path
|
|
36
|
+
raw_dim = dim if dim is not None else os.getenv("QDRANT_DIM")
|
|
37
|
+
self._dim = int(raw_dim) if raw_dim else None
|
|
38
|
+
self._client = client
|
|
39
|
+
self._store: QdrantVectorStore | None = None
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def name(self) -> str:
|
|
43
|
+
return "qdrant"
|
|
44
|
+
|
|
45
|
+
def vector_store(self) -> QdrantVectorStore:
|
|
46
|
+
if self._store is None:
|
|
47
|
+
self._store = QdrantVectorStore(
|
|
48
|
+
url=self._url,
|
|
49
|
+
api_key=self._api_key,
|
|
50
|
+
location=self._location or (":memory:" if not self._url and not self._path else None),
|
|
51
|
+
path=self._path,
|
|
52
|
+
dim=self._dim,
|
|
53
|
+
client=self._client,
|
|
54
|
+
)
|
|
55
|
+
return self._store
|
|
56
|
+
|
|
57
|
+
def register(self, kernel: object) -> None:
|
|
58
|
+
kernel.register("vector_store", self.vector_store(), replace=True)
|
|
59
|
+
|
|
60
|
+
def unregister(self, kernel: object) -> None:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
#: Auto-discovered by ``app.load_entry_points()``.
|
|
65
|
+
plugin = QdrantPlugin()
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Qdrant-backed :class:`~xyberos.contracts.VectorStore` (lazy ``qdrant-client``).
|
|
2
|
+
|
|
3
|
+
Each namespace maps to a Qdrant collection using cosine distance. Supports
|
|
4
|
+
hosted Qdrant (``url`` + optional ``api_key``) and local modes (``location=
|
|
5
|
+
":memory:"`` or a ``path``). The client is imported lazily on first use and a
|
|
6
|
+
clear :class:`~xyberos.exceptions.provider.ProviderError` is raised when
|
|
7
|
+
``qdrant-client`` is missing (``pip install xyberos[vectors]``).
|
|
8
|
+
|
|
9
|
+
Arbitrary string ids are mapped to deterministic UUIDs (Qdrant point ids are
|
|
10
|
+
``int`` or ``UUID``); the original id is preserved in the payload and restored
|
|
11
|
+
on query, so the :class:`VectorStore` contract (string ids) holds exactly.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import importlib
|
|
17
|
+
import uuid
|
|
18
|
+
from collections.abc import Mapping, Sequence
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from xyberos.contracts.vector import ScoredHit, VectorStore
|
|
22
|
+
from xyberos.exceptions.provider import ProviderError
|
|
23
|
+
|
|
24
|
+
_DEFAULT_DIM = 384
|
|
25
|
+
_ID_NAMESPACE = uuid.UUID("6ba7b810-9dad-11d1-80b4-00c04fd430c8") # DNS namespace
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _require_qdrant() -> Any:
|
|
29
|
+
try:
|
|
30
|
+
importlib.import_module("qdrant_client") # noqa: F401
|
|
31
|
+
models = importlib.import_module("qdrant_client.models")
|
|
32
|
+
except ImportError as exc:
|
|
33
|
+
raise ProviderError(
|
|
34
|
+
"the 'qdrant-client' package is required; install it with "
|
|
35
|
+
"'pip install xyberos[vectors]'"
|
|
36
|
+
) from exc
|
|
37
|
+
return models
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class QdrantVectorStore(VectorStore):
|
|
41
|
+
"""A :class:`VectorStore` backed by Qdrant (hosted or local)."""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
*,
|
|
46
|
+
url: str | None = None,
|
|
47
|
+
api_key: str | None = None,
|
|
48
|
+
location: str | None = None,
|
|
49
|
+
path: str | None = None,
|
|
50
|
+
dim: int | None = None,
|
|
51
|
+
prefer_grpc: bool = False,
|
|
52
|
+
client: Any | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
if dim is not None and dim <= 0:
|
|
55
|
+
raise ValueError("dim must be a positive integer")
|
|
56
|
+
self._url = url
|
|
57
|
+
self._api_key = api_key
|
|
58
|
+
self._location = location
|
|
59
|
+
self._path = path
|
|
60
|
+
self._dim = dim
|
|
61
|
+
self._prefer_grpc = prefer_grpc
|
|
62
|
+
self._client = client
|
|
63
|
+
self._known: set[str] = set()
|
|
64
|
+
|
|
65
|
+
# -- lifecycle ----------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
def start(self) -> None:
|
|
68
|
+
"""Connect to Qdrant (kernel lifecycle hook)."""
|
|
69
|
+
self._get_client()
|
|
70
|
+
|
|
71
|
+
def stop(self) -> None:
|
|
72
|
+
"""Release the client (kernel lifecycle hook)."""
|
|
73
|
+
self._client = None
|
|
74
|
+
self._known.clear()
|
|
75
|
+
|
|
76
|
+
# -- VectorStore contract -----------------------------------------------
|
|
77
|
+
|
|
78
|
+
def upsert(
|
|
79
|
+
self,
|
|
80
|
+
namespace: str,
|
|
81
|
+
id: str,
|
|
82
|
+
vector: Sequence[float],
|
|
83
|
+
payload: Mapping[str, Any] | None = None,
|
|
84
|
+
) -> None:
|
|
85
|
+
models = _require_qdrant()
|
|
86
|
+
client = self._get_client()
|
|
87
|
+
collection = self._ensure_collection(client, namespace, dim=len(vector), models=models)
|
|
88
|
+
stored = dict(payload or {})
|
|
89
|
+
stored["_id"] = str(id)
|
|
90
|
+
point = models.PointStruct(
|
|
91
|
+
id=self._point_id(id),
|
|
92
|
+
vector=[float(value) for value in vector],
|
|
93
|
+
payload=stored,
|
|
94
|
+
)
|
|
95
|
+
client.upsert(collection, points=[point])
|
|
96
|
+
|
|
97
|
+
def query(
|
|
98
|
+
self,
|
|
99
|
+
namespace: str,
|
|
100
|
+
vector: Sequence[float],
|
|
101
|
+
*,
|
|
102
|
+
top_k: int = 5,
|
|
103
|
+
threshold: float | None = None,
|
|
104
|
+
) -> list[ScoredHit]:
|
|
105
|
+
client = self._get_client()
|
|
106
|
+
if not self._collection_exists(client, namespace):
|
|
107
|
+
return []
|
|
108
|
+
result = client.query_points(
|
|
109
|
+
collection_name=namespace,
|
|
110
|
+
query=[float(value) for value in vector],
|
|
111
|
+
limit=max(top_k, 1),
|
|
112
|
+
with_payload=True,
|
|
113
|
+
)
|
|
114
|
+
hits: list[ScoredHit] = []
|
|
115
|
+
for point in result.points:
|
|
116
|
+
score = float(point.score)
|
|
117
|
+
if threshold is not None and score < threshold:
|
|
118
|
+
continue
|
|
119
|
+
payload = dict(point.payload or {})
|
|
120
|
+
stored_id = payload.pop("_id", None)
|
|
121
|
+
hits.append(
|
|
122
|
+
ScoredHit(
|
|
123
|
+
id=str(stored_id) if stored_id is not None else str(point.id),
|
|
124
|
+
score=score,
|
|
125
|
+
payload=payload or None,
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
return hits
|
|
129
|
+
|
|
130
|
+
def delete(self, namespace: str, id: str) -> None:
|
|
131
|
+
models = _require_qdrant()
|
|
132
|
+
client = self._get_client()
|
|
133
|
+
if not self._collection_exists(client, namespace):
|
|
134
|
+
return
|
|
135
|
+
client.delete(
|
|
136
|
+
collection_name=namespace,
|
|
137
|
+
points_selector=models.PointIdsList(points=[self._point_id(id)]),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def clear(self, namespace: str) -> None:
|
|
141
|
+
client = self._get_client()
|
|
142
|
+
if not self._collection_exists(client, namespace):
|
|
143
|
+
return
|
|
144
|
+
client.delete_collection(collection_name=namespace)
|
|
145
|
+
self._known.discard(namespace)
|
|
146
|
+
|
|
147
|
+
# -- helpers ------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
def _get_client(self) -> Any:
|
|
150
|
+
if self._client is not None:
|
|
151
|
+
return self._client
|
|
152
|
+
QdrantClient = importlib.import_module("qdrant_client").QdrantClient
|
|
153
|
+
|
|
154
|
+
if self._location is not None or self._path is not None:
|
|
155
|
+
self._client = QdrantClient(location=self._location, path=self._path)
|
|
156
|
+
else:
|
|
157
|
+
self._client = QdrantClient(
|
|
158
|
+
url=self._url,
|
|
159
|
+
api_key=self._api_key,
|
|
160
|
+
prefer_grpc=self._prefer_grpc,
|
|
161
|
+
)
|
|
162
|
+
return self._client
|
|
163
|
+
|
|
164
|
+
@staticmethod
|
|
165
|
+
def _point_id(id: str) -> Any:
|
|
166
|
+
"""Map an arbitrary string id to an int or UUID (Qdrant point id)."""
|
|
167
|
+
if isinstance(id, int):
|
|
168
|
+
return id
|
|
169
|
+
text = str(id)
|
|
170
|
+
if text.isdigit():
|
|
171
|
+
return int(text)
|
|
172
|
+
return uuid.uuid5(_ID_NAMESPACE, text)
|
|
173
|
+
|
|
174
|
+
def _ensure_collection(self, client: Any, namespace: str, *, dim: int, models: Any) -> str:
|
|
175
|
+
if namespace in self._known:
|
|
176
|
+
return namespace
|
|
177
|
+
if not self._collection_exists(client, namespace):
|
|
178
|
+
client.create_collection(
|
|
179
|
+
collection_name=namespace,
|
|
180
|
+
vectors_config=models.VectorParams(
|
|
181
|
+
size=self._dim or dim,
|
|
182
|
+
distance=models.Distance.COSINE,
|
|
183
|
+
),
|
|
184
|
+
)
|
|
185
|
+
self._known.add(namespace)
|
|
186
|
+
return namespace
|
|
187
|
+
|
|
188
|
+
def _collection_exists(self, client: Any, namespace: str) -> bool:
|
|
189
|
+
try:
|
|
190
|
+
client.get_collection(collection_name=namespace)
|
|
191
|
+
return True
|
|
192
|
+
except Exception:
|
|
193
|
+
return False
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xyberos-qdrant
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Qdrant VectorStore plugin (RFC-0019, M4): hosted or in-memory Qdrant as an xyberos VectorStore
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Keywords: xyberos,plugin,vector,qdrant,rag
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: xyberos>=1.0
|
|
10
|
+
Provides-Extra: vectors
|
|
11
|
+
Requires-Dist: qdrant-client; extra == "vectors"
|
|
12
|
+
Provides-Extra: test
|
|
13
|
+
Requires-Dist: pytest; extra == "test"
|
|
14
|
+
|
|
15
|
+
# xyberos-qdrant
|
|
16
|
+
|
|
17
|
+
**Qdrant `VectorStore` plugin — RFC-0019, M4.** Hosted or local [Qdrant](https://qdrant.tech)
|
|
18
|
+
as an `xyberos` `VectorStore`. Passes the same parity scenarios as the stdlib
|
|
19
|
+
`SqliteVectorStore` (the M4 Definition of Done).
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install -e ./qdrant
|
|
25
|
+
pip install qdrant-client # or: pip install xyberos[vectors]
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from xyberos import create_app
|
|
32
|
+
from xyberos_qdrant import QdrantPlugin
|
|
33
|
+
|
|
34
|
+
app = create_app()
|
|
35
|
+
app.load_plugin(QdrantPlugin(location=":memory:")) # local, no infrastructure
|
|
36
|
+
# hosted: QdrantPlugin(url="https://...", api_key=os.getenv("QDRANT_API_KEY"))
|
|
37
|
+
|
|
38
|
+
store = app.resolve("vector_store")
|
|
39
|
+
store.upsert("ns", "doc-1", [1.0, 0.0, 0.0, 0.0], {"text": "alpha"})
|
|
40
|
+
hits = store.query("ns", [1.0, 0.0, 0.0, 0.0], top_k=3)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Or configure through the environment (`QDRANT_URL`, `QDRANT_API_KEY`,
|
|
44
|
+
`QDRANT_DIM`) and let the `xyberos.plugins` entry point register it.
|
|
45
|
+
|
|
46
|
+
## Design
|
|
47
|
+
|
|
48
|
+
- Each **namespace** maps to a Qdrant collection using **cosine** distance
|
|
49
|
+
(higher score = more relevant, matching the `VectorStore` contract).
|
|
50
|
+
- The client is imported **lazily** and a clear `ProviderError` is raised when
|
|
51
|
+
`qdrant-client` is missing.
|
|
52
|
+
- Arbitrary string ids are mapped to deterministic UUIDs (Qdrant point ids are
|
|
53
|
+
`int`/`UUID`); the original id round-trips through the payload, so the
|
|
54
|
+
contract's string ids hold exactly.
|
|
55
|
+
|
|
56
|
+
## Tests
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install pytest
|
|
60
|
+
pytest tests/ # unit + parity + plugin tests
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Parity tests run against Qdrant's **in-memory local mode**, so they need no
|
|
64
|
+
server; they skip cleanly if `qdrant-client` is absent.
|
|
65
|
+
|
|
66
|
+
## Ship location
|
|
67
|
+
|
|
68
|
+
`[vectors]` extra (`qdrant-client` already added to `pyproject.toml`).
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
tests/test_parity.py
|
|
4
|
+
tests/test_plugin.py
|
|
5
|
+
tests/test_vector.py
|
|
6
|
+
xyberos_qdrant/__init__.py
|
|
7
|
+
xyberos_qdrant/plugin.py
|
|
8
|
+
xyberos_qdrant/vector.py
|
|
9
|
+
xyberos_qdrant.egg-info/PKG-INFO
|
|
10
|
+
xyberos_qdrant.egg-info/SOURCES.txt
|
|
11
|
+
xyberos_qdrant.egg-info/dependency_links.txt
|
|
12
|
+
xyberos_qdrant.egg-info/entry_points.txt
|
|
13
|
+
xyberos_qdrant.egg-info/requires.txt
|
|
14
|
+
xyberos_qdrant.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
xyberos_qdrant
|