ragleap-vectorstores 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.
@@ -0,0 +1,32 @@
1
+ # Environment
2
+ .env
3
+ *.env.local
4
+
5
+ # Python
6
+ __pycache__/
7
+ *.py[cod]
8
+ *.egg-info/
9
+ venv/
10
+ .venv/
11
+
12
+ # Node
13
+ node_modules/
14
+
15
+ # Docker
16
+ docker-compose.override.yml
17
+
18
+ # IDE
19
+ .vscode/
20
+ .idea/
21
+
22
+ # OS
23
+ .DS_Store
24
+ Thumbs.db
25
+
26
+ # Logs
27
+ *.log
28
+ .env
29
+
30
+ # Package build artifacts
31
+ dist/
32
+ build/
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ All notable changes to `ragleap-vectorstores` are documented here. Format
4
+ loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
+
6
+ ## [Unreleased]
7
+
8
+ ## [0.1.0] - 2026-08-29
9
+ ### Added
10
+ - Initial package scaffold - pyproject.toml, package layout matching
11
+ ragleap-graph's src/ragleap_vectorstores convention.
12
+ - `ChromaBackend` - first vector backend, implementing the full
13
+ `VectorBackend` interface via chromadb's embedded PersistentClient
14
+ (no server required). Available via the `chroma` optional extra
15
+ (`pip install ragleap-vectorstores[chroma]`).
16
+ - `supports_sparse()` correctly reports `False` for Chroma - no native
17
+ keyword/BM25 search as of chromadb 1.5.9, so hybrid search honestly
18
+ falls back to dense-only rather than claiming unimplemented capability.
@@ -0,0 +1,63 @@
1
+ Metadata-Version: 2.5
2
+ Name: ragleap-vectorstores
3
+ Version: 0.1.0
4
+ Summary: Pluggable vector backends beyond ragleap-rag core's 6 (PgVector, FAISS, Pinecone, Weaviate, Qdrant, Milvus) - additional VectorBackend implementations as optional extras, no vendor lock-in, no bloated core dependency footprint.
5
+ Project-URL: Homepage, https://github.com/antonyrag/ragleap-core
6
+ Project-URL: Repository, https://github.com/antonyrag/ragleap-core
7
+ Project-URL: Documentation, https://packages.ragleap.com/docs/ragleap-vectorstores.html
8
+ Project-URL: Issues, https://github.com/antonyrag/ragleap-core/issues
9
+ Author-email: Antony <antony@ragleap.com>
10
+ License-Expression: MIT
11
+ Keywords: rag,ragleap,vector-database,vector-search
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: ragleap-rag>=0.12.4
22
+ Provides-Extra: chroma
23
+ Requires-Dist: chromadb>=1.0.0; extra == 'chroma'
24
+ Provides-Extra: test
25
+ Requires-Dist: pytest-asyncio>=0.24.0; extra == 'test'
26
+ Requires-Dist: pytest>=8.0.0; extra == 'test'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # ragleap-vectorstores
30
+
31
+ Pluggable vector backends beyond [ragleap-rag](https://pypi.org/project/ragleap-rag/)'s
32
+ built-in 6 (PgVector, FAISS, Pinecone, Weaviate, Qdrant, Milvus).
33
+
34
+ This package is under active development - more backends will be added
35
+ over time. See the
36
+ [roadmap](https://github.com/antonyrag/ragleap-core/wiki/Roadmap) for status.
37
+
38
+ ## Available backends
39
+
40
+ | Backend | Extra | Notes |
41
+ |---|---|---|
42
+ | Chroma | `chroma` | Embedded/local via chromadb's PersistentClient - no server required. No native sparse/keyword search (`supports_sparse()` is `False`); hybrid search falls back to dense-only. |
43
+
44
+ ## Design
45
+
46
+ Every backend here implements ragleap-rag's `VectorBackend` interface, so it
47
+ can be passed directly to `RagLeap(vector_backend=...)`. Each backend's real
48
+ client SDK is an optional extra - installing `ragleap-vectorstores` alone
49
+ pulls in no heavy dependencies beyond `ragleap-rag` itself.
50
+
51
+ ## Install
52
+
53
+ ```bash
54
+ pip install ragleap-vectorstores[chroma]
55
+ ```
56
+
57
+ ## Usage
58
+
59
+ ```python
60
+ from ragleap_vectorstores import ChromaBackend
61
+
62
+ backend = ChromaBackend(persist_directory="./chroma_data")
63
+ ```
@@ -0,0 +1,35 @@
1
+ # ragleap-vectorstores
2
+
3
+ Pluggable vector backends beyond [ragleap-rag](https://pypi.org/project/ragleap-rag/)'s
4
+ built-in 6 (PgVector, FAISS, Pinecone, Weaviate, Qdrant, Milvus).
5
+
6
+ This package is under active development - more backends will be added
7
+ over time. See the
8
+ [roadmap](https://github.com/antonyrag/ragleap-core/wiki/Roadmap) for status.
9
+
10
+ ## Available backends
11
+
12
+ | Backend | Extra | Notes |
13
+ |---|---|---|
14
+ | Chroma | `chroma` | Embedded/local via chromadb's PersistentClient - no server required. No native sparse/keyword search (`supports_sparse()` is `False`); hybrid search falls back to dense-only. |
15
+
16
+ ## Design
17
+
18
+ Every backend here implements ragleap-rag's `VectorBackend` interface, so it
19
+ can be passed directly to `RagLeap(vector_backend=...)`. Each backend's real
20
+ client SDK is an optional extra - installing `ragleap-vectorstores` alone
21
+ pulls in no heavy dependencies beyond `ragleap-rag` itself.
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ pip install ragleap-vectorstores[chroma]
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ ```python
32
+ from ragleap_vectorstores import ChromaBackend
33
+
34
+ backend = ChromaBackend(persist_directory="./chroma_data")
35
+ ```
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "ragleap-vectorstores"
7
+ version = "0.1.0"
8
+ description = "Pluggable vector backends beyond ragleap-rag core's 6 (PgVector, FAISS, Pinecone, Weaviate, Qdrant, Milvus) - additional VectorBackend implementations as optional extras, no vendor lock-in, no bloated core dependency footprint."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ { name = "Antony", email = "antony@ragleap.com" }
14
+ ]
15
+ keywords = ["rag", "vector-search", "vector-database", "ragleap"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Topic :: Software Development :: Libraries :: Python Modules",
25
+ ]
26
+ dependencies = [
27
+ "ragleap-rag>=0.12.4",
28
+ ]
29
+
30
+ [project.optional-dependencies]
31
+ test = ["pytest>=8.0.0", "pytest-asyncio>=0.24.0"]
32
+ chroma = ["chromadb>=1.0.0"]
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/antonyrag/ragleap-core"
36
+ Repository = "https://github.com/antonyrag/ragleap-core"
37
+ Documentation = "https://packages.ragleap.com/docs/ragleap-vectorstores.html"
38
+ Issues = "https://github.com/antonyrag/ragleap-core/issues"
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["src/ragleap_vectorstores"]
@@ -0,0 +1,19 @@
1
+ """
2
+ ragleap-vectorstores: pluggable vector backends beyond ragleap-rag core's 6.
3
+
4
+ Each backend import is wrapped in try/except ImportError, matching
5
+ ragleap-rag's own vectorstores/__init__.py pattern - a missing optional
6
+ extra simply makes that one backend unavailable rather than breaking
7
+ the package.
8
+ """
9
+ from ragleap.vectorstores.base import VectorBackend
10
+
11
+ __version__ = "0.1.0"
12
+
13
+ __all__ = ["VectorBackend", "__version__"]
14
+
15
+ try:
16
+ from ragleap_vectorstores.chroma_backend import ChromaBackend
17
+ __all__.append("ChromaBackend")
18
+ except ImportError:
19
+ pass # chroma extra not installed - ChromaBackend simply unavailable, not an error
@@ -0,0 +1,203 @@
1
+ """
2
+ Chroma-backed vector storage for ragleap-vectorstores.
3
+
4
+ Live-verified against the actually installed chromadb==1.5.9 package:
5
+ every method signature and return shape used below was introspected and
6
+ exercised against a real local PersistentClient during development, not
7
+ assumed from documentation.
8
+
9
+ Design notes:
10
+ - persist_directory is REQUIRED. Chroma's PersistentClient already stores
11
+ chunk text and metadata durably on disk under this path, so - unlike
12
+ QdrantBackend/PineconeBackend/WeaviateBackend - no SQLite sidecar is
13
+ needed for chunk data. A small SQLite sidecar is still used, but only
14
+ for the document registry (filename, uploaded_at, metadata), because
15
+ Chroma has no native "parent document" concept - it only knows about
16
+ individual (id, embedding, metadata, document-text) chunk records.
17
+ - Chroma's `where=` filter requires exactly one top-level operator per
18
+ clause - live-verified: a plain multi-key dict like
19
+ {"document_id": "d1", "tag": "x"} raises ValueError ("Expected where to
20
+ have exactly one operator"). Multiple equality conditions must be
21
+ wrapped in {"$and": [...]}. _build_where() below handles this.
22
+ - similarity_score is derived as (1 - distance). Verified live with
23
+ "hnsw:space": "cosine" (the default configured here): querying with an
24
+ embedding identical to a stored one returns a distance ~0, consistent
25
+ with Chroma's documented cosine distance = 1 - cosine_similarity.
26
+ - supports_sparse() is False. Chroma has no native BM25/keyword search
27
+ surface as of 1.5.9 - only vector similarity - so hybrid search falls
28
+ back to the VectorBackend default (dense-only), matching the honest
29
+ "don't claim capability that isn't there" rule QdrantBackend follows.
30
+ """
31
+ import json
32
+ import logging
33
+ import os
34
+ import sqlite3
35
+ import threading
36
+ from typing import Dict, List, Optional
37
+
38
+ from ragleap.vectorstores.base import VectorBackend
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+
43
+ class ChromaBackend(VectorBackend):
44
+ def __init__(
45
+ self,
46
+ persist_directory: str,
47
+ collection_name: str = "ragleap",
48
+ ):
49
+ try:
50
+ import chromadb # noqa: F401
51
+ except ImportError as e:
52
+ raise ImportError(
53
+ "ChromaBackend requires the 'chroma' extra: "
54
+ "pip install ragleap-vectorstores[chroma]"
55
+ ) from e
56
+
57
+ if not persist_directory:
58
+ raise ValueError(
59
+ "ChromaBackend requires persist_directory= - this is where "
60
+ "both Chroma's own on-disk index and the document-registry "
61
+ "SQLite sidecar are stored."
62
+ )
63
+
64
+ self.persist_directory = persist_directory
65
+ self.collection_name = collection_name
66
+ self._client = None
67
+ self._collection = None
68
+ self._dimensions = None
69
+ self._lock = threading.Lock()
70
+
71
+ os.makedirs(persist_directory, exist_ok=True)
72
+ self._sqlite_path = os.path.join(persist_directory, "chroma_documents.sqlite3")
73
+ self._conn = sqlite3.connect(self._sqlite_path, check_same_thread=False)
74
+ self._conn.execute(
75
+ """CREATE TABLE IF NOT EXISTS documents (
76
+ id TEXT PRIMARY KEY, filename TEXT NOT NULL,
77
+ metadata TEXT NOT NULL, uploaded_at TEXT NOT NULL
78
+ )"""
79
+ )
80
+ self._conn.commit()
81
+
82
+ def _vector_key(self, document_id: str, chunk_index: int) -> str:
83
+ return f"{document_id}:{chunk_index}"
84
+
85
+ def _build_where(self, metadata_filter: Optional[Dict]):
86
+ """Chroma requires exactly one top-level operator per where-clause
87
+ (live-verified) - a plain multi-key dict raises ValueError. Wrap
88
+ multiple equality conditions in $and; pass single conditions
89
+ through directly."""
90
+ if not metadata_filter:
91
+ return None
92
+ if len(metadata_filter) == 1:
93
+ return dict(metadata_filter)
94
+ return {"$and": [{k: v} for k, v in metadata_filter.items()]}
95
+
96
+ def init_schema(self, dimensions: int) -> None:
97
+ import chromadb
98
+
99
+ self._dimensions = dimensions
100
+ self._client = chromadb.PersistentClient(path=self.persist_directory)
101
+ self._collection = self._client.get_or_create_collection(
102
+ name=self.collection_name,
103
+ metadata={"hnsw:space": "cosine"},
104
+ )
105
+ logger.info(
106
+ f"ChromaBackend: using collection '{self.collection_name}' "
107
+ f"(dimensions={dimensions}) at {self.persist_directory}"
108
+ )
109
+
110
+ def insert_document(self, document_id: str, filename: str, metadata: Dict) -> None:
111
+ import datetime
112
+ with self._lock:
113
+ self._conn.execute(
114
+ "INSERT INTO documents (id, filename, metadata, uploaded_at) VALUES (?, ?, ?, ?)",
115
+ (document_id, filename, json.dumps(metadata or {}), datetime.datetime.utcnow().isoformat()),
116
+ )
117
+ self._conn.commit()
118
+
119
+ def insert_chunk(
120
+ self, document_id: str, document_name: str, chunk_index: int,
121
+ text: str, token_count: int, embedding: List[float], metadata: Dict,
122
+ ) -> None:
123
+ vector_key = self._vector_key(document_id, chunk_index)
124
+ payload = {
125
+ "document_id": document_id, "document_name": document_name,
126
+ "chunk_index": chunk_index, "token_count": token_count or 0,
127
+ **(metadata or {}),
128
+ }
129
+ with self._lock:
130
+ self._collection.upsert(
131
+ ids=[vector_key],
132
+ embeddings=[embedding],
133
+ metadatas=[payload],
134
+ documents=[text],
135
+ )
136
+
137
+ def search_dense(self, embedding: List[float], top_k: int, metadata_filter: Optional[Dict] = None) -> List[Dict]:
138
+ if not embedding or self._collection is None:
139
+ return []
140
+
141
+ result = self._collection.query(
142
+ query_embeddings=[embedding],
143
+ n_results=top_k,
144
+ where=self._build_where(metadata_filter),
145
+ include=["documents", "metadatas", "distances"],
146
+ )
147
+
148
+ ids = result.get("ids") or [[]]
149
+ docs = result.get("documents") or [[]]
150
+ metas = result.get("metadatas") or [[]]
151
+ dists = result.get("distances") or [[]]
152
+ if not ids or not ids[0]:
153
+ return []
154
+
155
+ results = []
156
+ for chunk_id, text, meta, distance in zip(ids[0], docs[0], metas[0], dists[0]):
157
+ meta = meta or {}
158
+ results.append({
159
+ "chunk_id": chunk_id,
160
+ "text": text,
161
+ "similarity_score": round(1.0 - float(distance), 4),
162
+ "document_id": meta.get("document_id"),
163
+ "document_name": meta.get("document_name"),
164
+ "chunk_index": meta.get("chunk_index"),
165
+ })
166
+ return results
167
+
168
+ def supports_sparse(self) -> bool:
169
+ return False
170
+
171
+ def list_documents(self, limit: int, offset: int) -> List[Dict]:
172
+ rows = self._conn.execute(
173
+ "SELECT id, filename, uploaded_at, metadata FROM documents "
174
+ "ORDER BY uploaded_at DESC LIMIT ? OFFSET ?",
175
+ (limit, offset),
176
+ ).fetchall()
177
+
178
+ results = []
179
+ for doc_id, filename, uploaded_at, metadata_json in rows:
180
+ chunk_count = 0
181
+ if self._collection is not None:
182
+ chunk_rows = self._collection.get(where={"document_id": doc_id}, include=[])
183
+ chunk_count = len((chunk_rows or {}).get("ids", []))
184
+ results.append({
185
+ "document_id": doc_id, "filename": filename, "uploaded_at": uploaded_at,
186
+ "metadata": json.loads(metadata_json), "chunk_count": chunk_count,
187
+ })
188
+ return results
189
+
190
+ def delete_document(self, document_id: str) -> bool:
191
+ with self._lock:
192
+ if self._collection is not None:
193
+ self._collection.delete(where={"document_id": document_id})
194
+ cur = self._conn.execute("DELETE FROM documents WHERE id = ?", (document_id,))
195
+ deleted = cur.rowcount > 0
196
+ self._conn.commit()
197
+ return deleted
198
+
199
+ def get_document_filename(self, document_id: str) -> Optional[str]:
200
+ row = self._conn.execute(
201
+ "SELECT filename FROM documents WHERE id = ?", (document_id,)
202
+ ).fetchone()
203
+ return row[0] if row else None
@@ -0,0 +1,109 @@
1
+ """
2
+ Tests for ChromaBackend. Uses a real local chromadb PersistentClient
3
+ against a temp directory (Chroma has no meaningful "fake" - it's already
4
+ fully local/embedded, so there's no live-credentials gap to skip around,
5
+ unlike QdrantBackend/PineconeBackend/WeaviateBackend's tests).
6
+ """
7
+ import shutil
8
+
9
+ import pytest
10
+
11
+ from ragleap_vectorstores.chroma_backend import ChromaBackend
12
+
13
+
14
+ @pytest.fixture
15
+ def backend(tmp_path):
16
+ b = ChromaBackend(persist_directory=str(tmp_path / "chroma_data"))
17
+ b.init_schema(dimensions=3)
18
+ yield b
19
+
20
+
21
+ def _seed(backend):
22
+ backend.insert_document("doc1", "report.pdf", {"source": "upload"})
23
+ backend.insert_chunk("doc1", "report.pdf", 0, "The cat sat on the mat.", 6, [0.1, 0.2, 0.3], {"page": 1})
24
+ backend.insert_chunk("doc1", "report.pdf", 1, "Dogs bark loudly outside.", 5, [0.9, 0.1, 0.1], {"page": 2})
25
+ backend.insert_document("doc2", "notes.txt", {"source": "manual"})
26
+ backend.insert_chunk("doc2", "notes.txt", 0, "Unrelated content here.", 4, [0.5, 0.5, 0.5], {"page": 1})
27
+
28
+
29
+ def test_requires_persist_directory():
30
+ with pytest.raises(ValueError):
31
+ ChromaBackend(persist_directory="")
32
+
33
+
34
+ def test_insert_and_search_dense_shape(backend):
35
+ _seed(backend)
36
+ results = backend.search_dense([0.1, 0.2, 0.3], top_k=5)
37
+ assert results, "expected at least one result"
38
+ assert set(results[0].keys()) == {
39
+ "chunk_id", "text", "similarity_score", "document_id", "document_name", "chunk_index",
40
+ }
41
+ assert results[0]["chunk_id"] == "doc1:0"
42
+ assert results[0]["text"] == "The cat sat on the mat."
43
+
44
+
45
+ def test_search_dense_single_key_filter(backend):
46
+ _seed(backend)
47
+ results = backend.search_dense([0.1, 0.2, 0.3], top_k=5, metadata_filter={"document_id": "doc2"})
48
+ assert results
49
+ assert all(r["document_id"] == "doc2" for r in results)
50
+
51
+
52
+ def test_search_dense_multi_key_filter(backend):
53
+ """Regression guard for the real Chroma constraint (live-verified): a
54
+ plain multi-key `where=` dict raises ValueError - multiple conditions
55
+ must be wrapped in $and. This test would fail loudly if _build_where()
56
+ regressed to a naive dict pass-through."""
57
+ _seed(backend)
58
+ results = backend.search_dense([0.1, 0.2, 0.3], top_k=5, metadata_filter={"document_id": "doc1", "page": 2})
59
+ assert len(results) == 1
60
+ assert results[0]["chunk_id"] == "doc1:1"
61
+
62
+
63
+ def test_search_dense_empty_embedding_returns_empty(backend):
64
+ assert backend.search_dense([], top_k=5) == []
65
+
66
+
67
+ def test_search_sparse_not_supported(backend):
68
+ _seed(backend)
69
+ assert backend.search_sparse("cat", top_k=5) == []
70
+
71
+
72
+ def test_search_hybrid_falls_back_to_dense(backend):
73
+ _seed(backend)
74
+ hybrid = backend.search_hybrid("cat", [0.1, 0.2, 0.3], top_k=5)
75
+ dense = backend.search_dense([0.1, 0.2, 0.3], top_k=5)
76
+ assert [r["chunk_id"] for r in hybrid] == [r["chunk_id"] for r in dense]
77
+
78
+
79
+ def test_supports_sparse_is_false(backend):
80
+ assert backend.supports_sparse() is False
81
+
82
+
83
+ def test_list_documents(backend):
84
+ _seed(backend)
85
+ docs = backend.list_documents(limit=10, offset=0)
86
+ assert len(docs) == 2
87
+ by_id = {d["document_id"]: d for d in docs}
88
+ assert by_id["doc1"]["chunk_count"] == 2
89
+ assert by_id["doc2"]["chunk_count"] == 1
90
+ assert by_id["doc1"]["filename"] == "report.pdf"
91
+ assert by_id["doc1"]["metadata"] == {"source": "upload"}
92
+
93
+
94
+ def test_get_document_filename(backend):
95
+ _seed(backend)
96
+ assert backend.get_document_filename("doc1") == "report.pdf"
97
+ assert backend.get_document_filename("nonexistent") is None
98
+
99
+
100
+ def test_delete_document_removes_registry_entry_and_vectors(backend):
101
+ _seed(backend)
102
+ assert backend.delete_document("doc1") is True
103
+ assert backend.delete_document("doc1") is False # already gone
104
+
105
+ docs = backend.list_documents(limit=10, offset=0)
106
+ assert [d["document_id"] for d in docs] == ["doc2"]
107
+
108
+ remaining = backend.search_dense([0.1, 0.2, 0.3], top_k=5)
109
+ assert all(r["document_id"] != "doc1" for r in remaining)
@@ -0,0 +1,7 @@
1
+ """Smoke test - the package imports cleanly and VectorBackend is always
2
+ re-exported regardless of which optional backend extras are installed."""
3
+ import ragleap_vectorstores
4
+
5
+
6
+ def test_import():
7
+ assert "VectorBackend" in ragleap_vectorstores.__all__