vd 0.2.1__tar.gz → 0.2.2__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.
Files changed (68) hide show
  1. {vd-0.2.1 → vd-0.2.2}/.gitignore +3 -0
  2. {vd-0.2.1 → vd-0.2.2}/PKG-INFO +3 -1
  3. {vd-0.2.1 → vd-0.2.2}/pyproject.toml +2 -2
  4. vd-0.2.2/tests/README.md +60 -0
  5. vd-0.2.2/tests/conftest.py +218 -0
  6. vd-0.2.2/tests/docker-compose.yml +82 -0
  7. {vd-0.2.1 → vd-0.2.2}/tests/test_core.py +9 -4
  8. {vd-0.2.1 → vd-0.2.2}/vd/backends/elasticsearch.py +59 -47
  9. {vd-0.2.1 → vd-0.2.2}/vd/backends/milvus.py +6 -2
  10. {vd-0.2.1 → vd-0.2.2}/vd/backends/mongodb.py +170 -188
  11. {vd-0.2.1 → vd-0.2.2}/vd/backends/pgvector.py +20 -12
  12. {vd-0.2.1 → vd-0.2.2}/vd/backends/sqlite_vec.py +41 -20
  13. {vd-0.2.1 → vd-0.2.2}/vd/backends/weaviate.py +6 -4
  14. vd-0.2.1/tests/conftest.py +0 -62
  15. {vd-0.2.1 → vd-0.2.2}/.claude/CLAUDE.md +0 -0
  16. {vd-0.2.1 → vd-0.2.2}/.claude/skills/vd-add-backend/SKILL.md +0 -0
  17. {vd-0.2.1 → vd-0.2.2}/.claude/skills/vd-backend-choose/SKILL.md +0 -0
  18. {vd-0.2.1 → vd-0.2.2}/.claude/skills/vd-ingest/SKILL.md +0 -0
  19. {vd-0.2.1 → vd-0.2.2}/.claude/skills/vd-ops/SKILL.md +0 -0
  20. {vd-0.2.1 → vd-0.2.2}/.claude/skills/vd-quickstart/SKILL.md +0 -0
  21. {vd-0.2.1 → vd-0.2.2}/.claude/skills/vd-search/SKILL.md +0 -0
  22. {vd-0.2.1 → vd-0.2.2}/.gitattributes +0 -0
  23. {vd-0.2.1 → vd-0.2.2}/.github/workflows/ci.yml +0 -0
  24. {vd-0.2.1 → vd-0.2.2}/LICENSE +0 -0
  25. {vd-0.2.1 → vd-0.2.2}/README.md +0 -0
  26. {vd-0.2.1 → vd-0.2.2}/misc/docs/11 -- VectorDB Selection & Setup Guide -- Provider Profiles, Decision Framework, and Installation Playbooks.md +0 -0
  27. {vd-0.2.1 → vd-0.2.2}/misc/docs/notes_for_possible_improvements.md +0 -0
  28. {vd-0.2.1 → vd-0.2.2}/misc/docs/vd_design_notes.md +0 -0
  29. {vd-0.2.1 → vd-0.2.2}/misc/docs/vd_key_takeaways.md +0 -0
  30. {vd-0.2.1 → vd-0.2.2}/misc/docs/vd_specification.md +0 -0
  31. {vd-0.2.1 → vd-0.2.2}/misc/docs/vd_use_cases.md +0 -0
  32. {vd-0.2.1 → vd-0.2.2}/misc/examples/demo_backend_discovery.py +0 -0
  33. {vd-0.2.1 → vd-0.2.2}/misc/examples/demo_comparison.py +0 -0
  34. {vd-0.2.1 → vd-0.2.2}/misc/examples/demo_config.py +0 -0
  35. {vd-0.2.1 → vd-0.2.2}/misc/examples/demo_utilities.py +0 -0
  36. {vd-0.2.1 → vd-0.2.2}/misc/examples/example_usage.py +0 -0
  37. {vd-0.2.1 → vd-0.2.2}/tests/__init__.py +0 -0
  38. {vd-0.2.1 → vd-0.2.2}/tests/test_contract.py +0 -0
  39. {vd-0.2.1 → vd-0.2.2}/tests/test_filters.py +0 -0
  40. {vd-0.2.1 → vd-0.2.2}/tests/test_providers.py +0 -0
  41. {vd-0.2.1 → vd-0.2.2}/tests/test_time_indexed.py +0 -0
  42. {vd-0.2.1 → vd-0.2.2}/vd/__init__.py +0 -0
  43. {vd-0.2.1 → vd-0.2.2}/vd/analytics.py +0 -0
  44. {vd-0.2.1 → vd-0.2.2}/vd/backends/__init__.py +0 -0
  45. {vd-0.2.1 → vd-0.2.2}/vd/backends/_helpers.py +0 -0
  46. {vd-0.2.1 → vd-0.2.2}/vd/backends/chroma.py +0 -0
  47. {vd-0.2.1 → vd-0.2.2}/vd/backends/duckdb.py +0 -0
  48. {vd-0.2.1 → vd-0.2.2}/vd/backends/faiss.py +0 -0
  49. {vd-0.2.1 → vd-0.2.2}/vd/backends/lancedb.py +0 -0
  50. {vd-0.2.1 → vd-0.2.2}/vd/backends/memory.py +0 -0
  51. {vd-0.2.1 → vd-0.2.2}/vd/backends/pinecone.py +0 -0
  52. {vd-0.2.1 → vd-0.2.2}/vd/backends/qdrant.py +0 -0
  53. {vd-0.2.1 → vd-0.2.2}/vd/backends/redis.py +0 -0
  54. {vd-0.2.1 → vd-0.2.2}/vd/backends/turbopuffer.py +0 -0
  55. {vd-0.2.1 → vd-0.2.2}/vd/base.py +0 -0
  56. {vd-0.2.1 → vd-0.2.2}/vd/cli.py +0 -0
  57. {vd-0.2.1 → vd-0.2.2}/vd/config.py +0 -0
  58. {vd-0.2.1 → vd-0.2.2}/vd/data/providers.yaml +0 -0
  59. {vd-0.2.1 → vd-0.2.2}/vd/filters.py +0 -0
  60. {vd-0.2.1 → vd-0.2.2}/vd/health.py +0 -0
  61. {vd-0.2.1 → vd-0.2.2}/vd/io.py +0 -0
  62. {vd-0.2.1 → vd-0.2.2}/vd/migration.py +0 -0
  63. {vd-0.2.1 → vd-0.2.2}/vd/providers.py +0 -0
  64. {vd-0.2.1 → vd-0.2.2}/vd/requirements.py +0 -0
  65. {vd-0.2.1 → vd-0.2.2}/vd/search.py +0 -0
  66. {vd-0.2.1 → vd-0.2.2}/vd/text.py +0 -0
  67. {vd-0.2.1 → vd-0.2.2}/vd/time_indexed.py +0 -0
  68. {vd-0.2.1 → vd-0.2.2}/vd/util.py +0 -0
@@ -113,3 +113,6 @@ venv.bak/
113
113
 
114
114
  # PyCharm
115
115
  .idea
116
+
117
+ # Session handoff notes (local only)
118
+ .claude/handoffs/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vd
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: A facade over vector databases — one interface, ~15 backends
5
5
  Project-URL: Homepage, https://github.com/i2mint/vd
6
6
  License: mit
@@ -14,6 +14,7 @@ Requires-Dist: duckdb; extra == 'all-backends'
14
14
  Requires-Dist: elasticsearch; extra == 'all-backends'
15
15
  Requires-Dist: faiss-cpu; extra == 'all-backends'
16
16
  Requires-Dist: lancedb; extra == 'all-backends'
17
+ Requires-Dist: milvus-lite; (platform_system != 'Windows') and extra == 'all-backends'
17
18
  Requires-Dist: numpy; extra == 'all-backends'
18
19
  Requires-Dist: pgvector; extra == 'all-backends'
19
20
  Requires-Dist: pinecone; extra == 'all-backends'
@@ -63,6 +64,7 @@ Requires-Dist: numpy; extra == 'faiss'
63
64
  Provides-Extra: lancedb
64
65
  Requires-Dist: lancedb; extra == 'lancedb'
65
66
  Provides-Extra: milvus
67
+ Requires-Dist: milvus-lite; (platform_system != 'Windows') and extra == 'milvus'
66
68
  Requires-Dist: pymilvus; extra == 'milvus'
67
69
  Provides-Extra: mongodb
68
70
  Requires-Dist: pymongo; extra == 'mongodb'
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "vd"
7
- version = "0.2.1"
7
+ version = "0.2.2"
8
8
  description = "A facade over vector databases — one interface, ~15 backends"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -34,7 +34,7 @@ duckdb = ["duckdb"]
34
34
  pgvector = ["pgvector", "psycopg[binary]"]
35
35
  pinecone = ["pinecone"]
36
36
  weaviate = ["weaviate-client"]
37
- milvus = ["pymilvus"]
37
+ milvus = ["pymilvus", "milvus-lite ; platform_system != 'Windows'"]
38
38
  redis = ["redis", "numpy"]
39
39
  elasticsearch = ["elasticsearch"]
40
40
  mongodb = ["pymongo"]
@@ -0,0 +1,60 @@
1
+ # vd test suite
2
+
3
+ The headline file is `test_core.py` — the **facade contract suite**. Every test
4
+ there takes the `client` fixture, which is parametrized over every backend, so a
5
+ single test asserts the same behaviour across all of them. That is how `vd`
6
+ proves one uniform interface over many vector databases.
7
+
8
+ ## Backends the suite sweeps over
9
+
10
+ `conftest.py` defines two groups:
11
+
12
+ - **Embedded backends** — `memory`, `chroma`, `faiss`, `duckdb`, `lancedb`,
13
+ `qdrant`, `sqlite_vec`, `milvus`. No server needed; each test gets a fresh
14
+ client. `sqlite_vec` is skipped on a Python whose `sqlite3` lacks
15
+ loadable-extension support; `milvus` runs against the embedded **Milvus Lite**
16
+ engine and is skipped if `milvus-lite` is not installed.
17
+ - **Server backends** — `pgvector`, `redis`, `elasticsearch`, `weaviate`,
18
+ `mongodb`. Each needs a running container. A backend whose port is not open is
19
+ **skipped**, so the suite stays green in a plain CI environment.
20
+
21
+ ## Running against the server backends
22
+
23
+ ```bash
24
+ # Start every server backend (first run pulls images — a few minutes).
25
+ docker compose -f tests/docker-compose.yml up -d
26
+
27
+ # Wait until all five report (healthy), then run the suite.
28
+ docker compose -f tests/docker-compose.yml ps
29
+
30
+ pytest tests/
31
+
32
+ # Tear down and wipe volumes when done.
33
+ docker compose -f tests/docker-compose.yml down -v
34
+ ```
35
+
36
+ Run one backend at a time with `-k`:
37
+
38
+ ```bash
39
+ pytest tests/test_core.py -k pgvector
40
+ ```
41
+
42
+ ## Connection settings
43
+
44
+ The server backends use these defaults; override with environment variables:
45
+
46
+ | Backend | Env var | Default |
47
+ |-----------------|-----------------------|------------------------------------------------------|
48
+ | `pgvector` | `VD_PGVECTOR_DSN` | `postgresql://vd:vd@localhost:5432/vd` |
49
+ | `redis` | `VD_REDIS_HOST` / `VD_REDIS_PORT` | `localhost` / `6379` |
50
+ | `elasticsearch` | `VD_ELASTICSEARCH_URL`| `http://localhost:9200` |
51
+ | `weaviate` | `VD_WEAVIATE_HOST` | `localhost` |
52
+ | `mongodb` | `VD_MONGODB_URI` | `mongodb://localhost:27018/?directConnection=true` |
53
+
54
+ `mongodb` maps to host port **27018** (not 27017) to avoid colliding with a
55
+ developer's native `mongod`. It uses the `mongodb-atlas-local` image because
56
+ `$vectorSearch` requires an Atlas-capable deployment.
57
+
58
+ `milvus` needs no container — it is verified against the embedded Milvus Lite
59
+ engine, which exercises the same adapter code path as a Milvus server (only the
60
+ client constructor differs).
@@ -0,0 +1,218 @@
1
+ """
2
+ Pytest fixtures shared across the vd test suite.
3
+
4
+ The headline fixture is :func:`client` — parametrized over every backend vd
5
+ can reach in the current environment. A test that takes ``client`` runs once
6
+ per backend, which is how the suite proves the facade contract holds uniformly.
7
+
8
+ Two kinds of backend run:
9
+
10
+ - **Embedded backends** (``memory``, ``chroma``, ``faiss``, ``duckdb``,
11
+ ``lancedb``, ``qdrant``, ``sqlite_vec``, ``milvus``) need no server. Each
12
+ test gets a fresh client. ``sqlite_vec`` is skipped on a Python whose
13
+ ``sqlite3`` lacks loadable-extension support; ``milvus`` runs against the
14
+ embedded Milvus Lite engine and is skipped if ``milvus-lite`` is absent.
15
+ - **Server backends** (``pgvector``, ``redis``, ``elasticsearch``,
16
+ ``weaviate``, ``mongodb``) need a running container — see
17
+ ``tests/docker-compose.yml``. Each is TCP-probed and **skipped** when its
18
+ container is down, so the suite stays green in a plain CI environment.
19
+
20
+ Connection settings for the server backends are environment-overridable
21
+ (``VD_PGVECTOR_DSN``, ``VD_REDIS_HOST``/``VD_REDIS_PORT``,
22
+ ``VD_ELASTICSEARCH_URL``, ``VD_WEAVIATE_HOST``, ``VD_MONGODB_URI``).
23
+ """
24
+
25
+ import hashlib
26
+ import importlib.util
27
+ import os
28
+ import socket
29
+ import sqlite3
30
+
31
+ import pytest
32
+
33
+ import vd
34
+
35
+ #: Embedding dimension used by the test embedder.
36
+ EMBED_DIM = 16
37
+
38
+ #: Backends that need no server. Each test gets a fresh client.
39
+ EMBEDDED_BACKENDS = [
40
+ "memory",
41
+ "chroma",
42
+ "faiss",
43
+ "duckdb",
44
+ "lancedb",
45
+ "qdrant",
46
+ "sqlite_vec",
47
+ "milvus", # verified against the embedded Milvus Lite engine
48
+ ]
49
+
50
+ #: Server backends — each needs a container (``tests/docker-compose.yml``).
51
+ #: ``probe`` is TCP-probed; the backend is skipped when the port is closed.
52
+ #: ``connect_kwargs`` builds the :func:`vd.connect` arguments (env-overridable).
53
+ SERVER_BACKENDS = {
54
+ "pgvector": {
55
+ "probe": ("localhost", 5432),
56
+ "connect_kwargs": lambda: {
57
+ "dsn": os.environ.get(
58
+ "VD_PGVECTOR_DSN", "postgresql://vd:vd@localhost:5432/vd"
59
+ )
60
+ },
61
+ },
62
+ "redis": {
63
+ "probe": ("localhost", 6379),
64
+ "connect_kwargs": lambda: {
65
+ "host": os.environ.get("VD_REDIS_HOST", "localhost"),
66
+ "port": int(os.environ.get("VD_REDIS_PORT", "6379")),
67
+ },
68
+ },
69
+ "elasticsearch": {
70
+ "probe": ("localhost", 9200),
71
+ "connect_kwargs": lambda: {
72
+ "url": os.environ.get("VD_ELASTICSEARCH_URL", "http://localhost:9200")
73
+ },
74
+ },
75
+ "weaviate": {
76
+ "probe": ("localhost", 8080),
77
+ "connect_kwargs": lambda: {
78
+ "host": os.environ.get("VD_WEAVIATE_HOST", "localhost")
79
+ },
80
+ },
81
+ "mongodb": {
82
+ # Host port 27018 — see tests/docker-compose.yml (avoids colliding
83
+ # with a developer's native mongod on the default 27017).
84
+ "probe": ("localhost", 27018),
85
+ "connect_kwargs": lambda: {
86
+ "uri": os.environ.get(
87
+ "VD_MONGODB_URI", "mongodb://localhost:27018/?directConnection=true"
88
+ )
89
+ },
90
+ },
91
+ }
92
+
93
+ #: Every backend the parametrized ``client`` fixture sweeps over.
94
+ ALL_BACKENDS = EMBEDDED_BACKENDS + list(SERVER_BACKENDS)
95
+
96
+
97
+ # --------------------------------------------------------------------------- #
98
+ # Availability probes
99
+ # --------------------------------------------------------------------------- #
100
+
101
+
102
+ def _tcp_open(host: str, port: int, timeout: float = 0.5) -> bool:
103
+ """Return ``True`` if a TCP connection to ``host:port`` succeeds."""
104
+ try:
105
+ with socket.create_connection((host, port), timeout=timeout):
106
+ return True
107
+ except OSError:
108
+ return False
109
+
110
+
111
+ def _sqlite_ext_supported() -> bool:
112
+ """Return ``True`` if this Python's sqlite3 supports loadable extensions."""
113
+ try:
114
+ conn = sqlite3.connect(":memory:")
115
+ ok = hasattr(conn, "enable_load_extension")
116
+ conn.close()
117
+ return ok
118
+ except Exception:
119
+ return False
120
+
121
+
122
+ def _unavailable_reason(name: str) -> str | None:
123
+ """Return a skip reason for backend ``name``, or ``None`` if it can run."""
124
+ if name == "sqlite_vec" and not _sqlite_ext_supported():
125
+ return "sqlite3 was built without loadable-extension support"
126
+ if name == "milvus" and importlib.util.find_spec("milvus_lite") is None:
127
+ return "milvus-lite not installed (embedded Milvus engine unavailable)"
128
+ if name in SERVER_BACKENDS:
129
+ host, port = SERVER_BACKENDS[name]["probe"]
130
+ if not _tcp_open(host, port):
131
+ return (
132
+ f"{name!r} server unreachable at {host}:{port} "
133
+ f"— start it with tests/docker-compose.yml"
134
+ )
135
+ return None
136
+
137
+
138
+ def _connect_kwargs(name: str) -> dict:
139
+ """Return the :func:`vd.connect` kwargs for backend ``name``."""
140
+ if name in SERVER_BACKENDS:
141
+ return SERVER_BACKENDS[name]["connect_kwargs"]()
142
+ return {}
143
+
144
+
145
+ def _drop_all_collections(client) -> None:
146
+ """
147
+ Delete every collection on ``client`` — best-effort.
148
+
149
+ Server backends keep state across runs; embedded backends are fresh each
150
+ time. Dropping everything before and after each test makes a run against a
151
+ live server idempotent (a re-run does not trip "already exists").
152
+ """
153
+ try:
154
+ names = list(client.list_collections())
155
+ except Exception:
156
+ return
157
+ for name in names:
158
+ try:
159
+ client.delete_collection(name)
160
+ except Exception:
161
+ pass
162
+
163
+
164
+ # --------------------------------------------------------------------------- #
165
+ # Test embedder
166
+ # --------------------------------------------------------------------------- #
167
+
168
+
169
+ def make_embedder():
170
+ """Return a deterministic ``text -> 16-dim vector`` embedder for tests."""
171
+
172
+ def embed(text: str) -> list[float]:
173
+ digest = hashlib.md5(text.encode()).digest()
174
+ vector = [(b / 128.0) - 1.0 for b in digest]
175
+ while len(vector) < EMBED_DIM:
176
+ vector += vector
177
+ return vector[:EMBED_DIM]
178
+
179
+ return embed
180
+
181
+
182
+ @pytest.fixture
183
+ def embedder():
184
+ """A deterministic test embedder."""
185
+ return make_embedder()
186
+
187
+
188
+ # --------------------------------------------------------------------------- #
189
+ # Backend fixtures
190
+ # --------------------------------------------------------------------------- #
191
+
192
+
193
+ @pytest.fixture(params=ALL_BACKENDS)
194
+ def backend_name(request):
195
+ """Each reachable backend, one at a time; unreachable ones are skipped."""
196
+ name = request.param
197
+ if name not in vd.list_backends():
198
+ pytest.skip(f"backend {name!r} is not installed")
199
+ reason = _unavailable_reason(name)
200
+ if reason:
201
+ pytest.skip(reason)
202
+ return name
203
+
204
+
205
+ @pytest.fixture
206
+ def client(backend_name, embedder):
207
+ """A fresh, connected client for each backend (with an embedder)."""
208
+ connection = vd.connect(
209
+ backend_name, embedder=embedder, **_connect_kwargs(backend_name)
210
+ )
211
+ _drop_all_collections(connection)
212
+ yield connection
213
+ _drop_all_collections(connection)
214
+ if hasattr(connection, "close"):
215
+ try:
216
+ connection.close()
217
+ except Exception:
218
+ pass
@@ -0,0 +1,82 @@
1
+ # Server backends for vd's live-verification test suite.
2
+ #
3
+ # Brings up one container per server-backed vd backend. The pytest suite
4
+ # (see conftest.py) TCP-probes each port and runs the contract tests against
5
+ # whatever is reachable; backends whose container is down are skipped, so the
6
+ # suite stays green in a plain CI environment.
7
+ #
8
+ # docker compose -f tests/docker-compose.yml up -d # start
9
+ # pytest tests/ # run the suite
10
+ # docker compose -f tests/docker-compose.yml down -v # stop + wipe
11
+ #
12
+ # milvus is intentionally absent: the milvus adapter is verified against the
13
+ # embedded Milvus Lite engine (no server), which exercises the same code path.
14
+
15
+ services:
16
+ pgvector:
17
+ image: pgvector/pgvector:pg17
18
+ environment:
19
+ POSTGRES_USER: vd
20
+ POSTGRES_PASSWORD: vd
21
+ POSTGRES_DB: vd
22
+ ports:
23
+ - "5432:5432"
24
+ healthcheck:
25
+ test: ["CMD-SHELL", "pg_isready -U vd"]
26
+ interval: 3s
27
+ timeout: 5s
28
+ retries: 20
29
+
30
+ redis:
31
+ image: redis/redis-stack-server:latest
32
+ ports:
33
+ - "6379:6379"
34
+ healthcheck:
35
+ test: ["CMD", "redis-cli", "ping"]
36
+ interval: 3s
37
+ timeout: 5s
38
+ retries: 20
39
+
40
+ elasticsearch:
41
+ image: docker.elastic.co/elasticsearch/elasticsearch:9.0.3
42
+ environment:
43
+ discovery.type: single-node
44
+ xpack.security.enabled: "false"
45
+ ES_JAVA_OPTS: "-Xms512m -Xmx512m"
46
+ ports:
47
+ - "9200:9200"
48
+ healthcheck:
49
+ test: ["CMD-SHELL", "curl -sf http://localhost:9200/_cluster/health || exit 1"]
50
+ interval: 5s
51
+ timeout: 5s
52
+ retries: 30
53
+
54
+ weaviate:
55
+ image: cr.weaviate.io/semitechnologies/weaviate:1.28.4
56
+ command: ["--host", "0.0.0.0", "--port", "8080", "--scheme", "http"]
57
+ environment:
58
+ AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED: "true"
59
+ PERSISTENCE_DATA_PATH: "/var/lib/weaviate"
60
+ DEFAULT_VECTORIZER_MODULE: "none"
61
+ ENABLE_MODULES: ""
62
+ QUERY_DEFAULTS_LIMIT: "25"
63
+ ports:
64
+ - "8080:8080"
65
+ - "50051:50051"
66
+ healthcheck:
67
+ test: ["CMD-SHELL", "wget -qO- http://localhost:8080/v1/.well-known/ready || exit 1"]
68
+ interval: 5s
69
+ timeout: 5s
70
+ retries: 30
71
+
72
+ mongodb:
73
+ image: mongodb/mongodb-atlas-local:latest
74
+ # Host port 27018 (not 27017) to avoid colliding with a developer's
75
+ # native mongod. The container still listens on 27017 internally.
76
+ ports:
77
+ - "27018:27017"
78
+ healthcheck:
79
+ test: ["CMD-SHELL", "mongosh --quiet --eval 'db.runCommand({ ping: 1 })' || exit 1"]
80
+ interval: 5s
81
+ timeout: 5s
82
+ retries: 30
@@ -166,7 +166,9 @@ def test_search_with_comparison_filter(client):
166
166
  col = client.create_collection("search_cmp")
167
167
  for i in range(6):
168
168
  v = [0.0] * 16
169
- v[0] = i / 6
169
+ # (i + 1), not i — a zero-magnitude vector has undefined cosine
170
+ # similarity and some backends (Elasticsearch) reject it outright.
171
+ v[0] = (i + 1) / 6
170
172
  col[str(i)] = _doc(str(i), v, n=i)
171
173
  hits = list(col.search([0.5] * 16, limit=10, filter={"n": {"$gte": 3}}))
172
174
  assert sorted(int(h["id"]) for h in hits) == [3, 4, 5]
@@ -176,7 +178,8 @@ def test_search_with_in_filter(client):
176
178
  col = client.create_collection("search_in")
177
179
  for i in range(5):
178
180
  v = [0.0] * 16
179
- v[0] = i / 5
181
+ # (i + 1), not i — avoid a zero-magnitude vector (see comment above).
182
+ v[0] = (i + 1) / 5
180
183
  col[str(i)] = _doc(str(i), v, n=i)
181
184
  hits = list(col.search([0.5] * 16, limit=10, filter={"n": {"$in": [1, 3]}}))
182
185
  assert sorted(int(h["id"]) for h in hits) == [1, 3]
@@ -229,6 +232,8 @@ def test_upsert_is_idempotent(client):
229
232
 
230
233
  def test_dimension_mismatch_raises(client):
231
234
  col = client.create_collection("dimcheck")
232
- col["ok"] = Document(id="ok", text="t", vector=[0.0] * 16)
235
+ # A non-zero vector — a zero-magnitude one has undefined cosine similarity
236
+ # and some backends (Elasticsearch) reject it outright.
237
+ col["ok"] = Document(id="ok", text="t", vector=[1.0] * 16)
233
238
  with pytest.raises(ValueError):
234
- col["bad"] = Document(id="bad", text="t", vector=[0.0] * 8)
239
+ col["bad"] = Document(id="bad", text="t", vector=[1.0] * 8)
@@ -195,16 +195,28 @@ class ElasticsearchCollection(AbstractCollection):
195
195
 
196
196
  def _ensure_index(self) -> None:
197
197
  """
198
- Create the Elasticsearch index on the first write if it does not exist.
199
-
200
- The dimension must be known (set) before this is called, which is
201
- guaranteed because :meth:`~vd.base.AbstractCollection._vet_vector`
202
- sets ``self.dimension`` from the first vector before ``_write`` runs.
198
+ Ensure the ``embedding`` dense_vector field is mapped before a write.
199
+
200
+ The index itself is created eagerly by
201
+ :meth:`ElasticsearchClient.create_collection`, so an empty collection
202
+ is still visible to ``list_collections`` / ``in`` / ``get_collection``.
203
+ The ``dense_vector`` field, however, needs the vector dimension — not
204
+ known until the first vector arrives — so it is added here, lazily, via
205
+ ``put_mapping``. ``self.dimension`` is guaranteed set by then because
206
+ :meth:`~vd.base.AbstractCollection._vet_vector` runs before ``_write``.
203
207
  """
208
+ full_mapping = _build_mapping(self.dimension, self.metric)
204
209
  if not _index_exists(self._es, self.name):
205
- self._es.indices.create(
210
+ # Index missing (collection created outside the adapter, or the
211
+ # index was dropped) — create it whole.
212
+ self._es.indices.create(index=self.name, mappings=full_mapping)
213
+ return
214
+ mapping = self._es.indices.get_mapping(index=self.name)
215
+ properties = mapping[self.name]["mappings"].get("properties", {})
216
+ if "embedding" not in properties:
217
+ self._es.indices.put_mapping(
206
218
  index=self.name,
207
- mappings=_build_mapping(self.dimension, self.metric),
219
+ properties={"embedding": full_mapping["properties"]["embedding"]},
208
220
  )
209
221
 
210
222
  # ----- raw primitives --------------------------------------------------- #
@@ -276,37 +288,26 @@ class ElasticsearchCollection(AbstractCollection):
276
288
  """
277
289
  Iterate all document ids in the index.
278
290
 
279
- Uses a ``match_all`` query with pagination (``search_after``) to
280
- retrieve ids page by page. For very large indices (tens of millions of
281
- documents) prefer the Scroll API or the ``helpers.scan`` utility; the
282
- ``search_after`` approach used here avoids the deprecated Scroll deep
283
- pagination but still loads all ids into memory.
291
+ Uses the scroll-based ``helpers.scan`` to walk every document.
292
+ Elasticsearch disallows sorting (fielddata access) on the ``_id``
293
+ field, which rules out an ``_id``-keyed ``search_after`` pagination;
294
+ ``scan`` needs no sort. Ids are materialised into a list (as the
295
+ previous ``search_after`` implementation also did).
284
296
  """
285
297
  if not _index_exists(self._es, self.name):
286
298
  return iter(())
299
+ from elasticsearch.helpers import scan
287
300
 
288
- ids: list[str] = []
289
- search_after: Any = None
290
- while True:
291
- body: dict = {
292
- "query": {"match_all": {}},
293
- "size": _KEYS_PAGE_SIZE,
294
- "_source": False,
295
- "sort": [{"_id": "asc"}],
296
- }
297
- if search_after is not None:
298
- body["search_after"] = search_after
299
-
300
- response = self._es.search(index=self.name, body=body)
301
- hits = response["hits"]["hits"]
302
- if not hits:
303
- break
304
- for hit in hits:
305
- ids.append(hit["_id"])
306
- search_after = hits[-1]["sort"]
307
- if len(hits) < _KEYS_PAGE_SIZE:
308
- break
309
-
301
+ ids = [
302
+ hit["_id"]
303
+ for hit in scan(
304
+ self._es,
305
+ index=self.name,
306
+ query={"query": {"match_all": {}}},
307
+ _source=False,
308
+ size=_KEYS_PAGE_SIZE,
309
+ )
310
+ ]
310
311
  return iter(ids)
311
312
 
312
313
  def _count(self) -> int:
@@ -441,8 +442,12 @@ class ElasticsearchClient(AbstractClient):
441
442
  """
442
443
  Create a new collection (Elasticsearch index).
443
444
 
444
- The index is created **lazily** on the first write when ``dimension``
445
- is ``None``, or **eagerly** here when ``dimension`` is supplied.
445
+ The index is created **eagerly** so the collection is immediately
446
+ visible to :meth:`list_collections`, ``in``, and :meth:`get_collection`
447
+ even before its first write. The ``embedding`` ``dense_vector`` field
448
+ is added with a full mapping now when ``dimension`` is supplied, or
449
+ **lazily** on the first write otherwise (Elasticsearch needs the vector
450
+ dimension to map a ``dense_vector`` field).
446
451
 
447
452
  Parameters
448
453
  ----------
@@ -450,8 +455,8 @@ class ElasticsearchClient(AbstractClient):
450
455
  Index name. **Must be lowercase** — Elasticsearch rejects names
451
456
  with uppercase letters.
452
457
  dimension : int, optional
453
- Vector dimension. Required for eager index creation; may be
454
- deferred until the first write.
458
+ Vector dimension. When given, the ``dense_vector`` field is mapped
459
+ now; otherwise it is deferred until the first write.
455
460
  metric : str
456
461
  Distance metric: ``"cosine"``, ``"dot"``, or ``"l2"``.
457
462
  **index_config
@@ -472,16 +477,23 @@ class ElasticsearchClient(AbstractClient):
472
477
  dimension=dimension,
473
478
  metric=metric,
474
479
  )
475
- if dimension is not None:
476
- # Eager index creation: build the mapping now.
477
- settings = index_config if index_config else None
478
- create_kwargs: dict[str, Any] = {
479
- "index": name,
480
- "mappings": _build_mapping(dimension, metric),
480
+ # Always create the index now (an empty collection must still be
481
+ # listable). The dense_vector field needs the dimension, so it is only
482
+ # mapped here when known — otherwise _ensure_index adds it on first write.
483
+ mappings: dict[str, Any] = {
484
+ "properties": {
485
+ "text": {"type": "text"},
486
+ "metadata": {"type": "object", "enabled": False},
481
487
  }
482
- if settings:
483
- create_kwargs["settings"] = settings
484
- self._client.indices.create(**create_kwargs)
488
+ }
489
+ if dimension is not None:
490
+ mappings["properties"]["embedding"] = _build_mapping(dimension, metric)[
491
+ "properties"
492
+ ]["embedding"]
493
+ create_kwargs: dict[str, Any] = {"index": name, "mappings": mappings}
494
+ if index_config:
495
+ create_kwargs["settings"] = index_config
496
+ self._client.indices.create(**create_kwargs)
485
497
  return col
486
498
 
487
499
  def get_collection(self, name: str) -> ElasticsearchCollection:
@@ -45,6 +45,7 @@ Requires: ``pip install pymilvus``
45
45
 
46
46
  from __future__ import annotations
47
47
 
48
+ import os
48
49
  import tempfile
49
50
  from typing import Any, Callable, Iterable, Iterator, Optional
50
51
 
@@ -514,8 +515,11 @@ class MilvusBackendClient(AbstractClient):
514
515
  elif uri is not None:
515
516
  self._client = MilvusClient(uri=uri, token=token)
516
517
  else:
517
- # Milvus Lite: create a temporary .db file.
518
- _fd, tmp_path = tempfile.mkstemp(suffix=".db", prefix="vd_milvus_")
518
+ # Milvus Lite: an embedded, server-less engine. Hand it a fresh
519
+ # ".db" path inside a new temp directory. milvus-lite >=3.0 treats
520
+ # the path as a data directory it creates itself, so it must not
521
+ # already exist as a file (which ``tempfile.mkstemp`` would create).
522
+ tmp_path = os.path.join(tempfile.mkdtemp(prefix="vd_milvus_"), "milvus.db")
519
523
  self._client = MilvusClient(tmp_path)
520
524
 
521
525
  #: Track metrics for collections created via this client so they survive