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.
- ragleap_vectorstores-0.1.0/.gitignore +32 -0
- ragleap_vectorstores-0.1.0/CHANGELOG.md +18 -0
- ragleap_vectorstores-0.1.0/PKG-INFO +63 -0
- ragleap_vectorstores-0.1.0/README.md +35 -0
- ragleap_vectorstores-0.1.0/pyproject.toml +41 -0
- ragleap_vectorstores-0.1.0/src/ragleap_vectorstores/__init__.py +19 -0
- ragleap_vectorstores-0.1.0/src/ragleap_vectorstores/chroma_backend.py +203 -0
- ragleap_vectorstores-0.1.0/tests/test_chroma_backend.py +109 -0
- ragleap_vectorstores-0.1.0/tests/test_smoke.py +7 -0
|
@@ -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)
|