raglite-toolkit 1.2.0__tar.gz → 1.2.1__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.
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/CHANGELOG.md +18 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/PKG-INFO +3 -2
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/README.md +1 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/pyproject.toml +1 -1
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/config.py +5 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/constants.py +1 -1
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/core/collection.py +21 -3
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/core/document.py +35 -21
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/loaders/web.py +2 -1
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/vectordb/qdrant.py +31 -9
- raglite_toolkit-1.2.1/tests/unit/test_regressions.py +210 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/.github/workflows/pr-verify.yml +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/.github/workflows/publish.yml +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/.gitignore +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/ARCHITECTURE.md +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/LICENSE +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/examples/basic.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/examples/custom_store_example.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/examples/multi_provider.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/examples/ollama_test.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/examples/qdrant_example.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/examples/sample.txt +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/examples/serve.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/scripts/pre-commit.sh +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/scripts/pre-release.sh +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/__init__.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/api/__init__.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/api/schemas.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/api/server.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/chunking/__init__.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/chunking/base.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/chunking/recursive.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/cli.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/core/__init__.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/embeddings/__init__.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/embeddings/base.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/embeddings/factory.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/embeddings/local.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/embeddings/models.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/embeddings/remote.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/errors.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/llm/__init__.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/llm/answer.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/llm/factory.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/llm/models.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/llm/prompt.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/loaders/__init__.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/loaders/base.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/loaders/directory.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/loaders/docx.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/loaders/json.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/loaders/markdown.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/loaders/pdf.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/loaders/txt.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/retrieval/__init__.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/retrieval/retriever.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/types.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/utils/__init__.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/utils/hash.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/utils/logger.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/vectordb/__init__.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/vectordb/base.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/vectordb/factory.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/vectordb/memory.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/vectordb/pinecone.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/tests/__init__.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/tests/conftest.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/tests/integration/__init__.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/tests/integration/test_api.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/tests/integration/test_ask.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/tests/integration/test_collection.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/tests/integration/test_document.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/tests/integration/test_ollama.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/tests/unit/__init__.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/tests/unit/test_chunking.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/tests/unit/test_cli.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/tests/unit/test_config.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/tests/unit/test_directory_loader.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/tests/unit/test_errors.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/tests/unit/test_hash.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/tests/unit/test_loaders.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/tests/unit/test_prompt.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/tests/unit/test_retriever.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/tests/unit/test_vectordb.py +0 -0
- {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/tests/unit/test_web_loader.py +0 -0
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [1.2.1] - 2026-10-02
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- **URL sources:** `Document.build()` no longer fails with `LoaderError: File does not exist` for web URLs, so URLs work on their own and inside a `DocumentCollection`.
|
|
9
|
+
- **Custom `VectorStore` instances:** Passing a `VectorStore` subclass instance as `{"vectorStore": store}` no longer fails config validation, so the documented custom store usage works.
|
|
10
|
+
- **Qdrant shared collections:** When `indexName` is set, documents sharing one Qdrant collection no longer wipe each other. `reset()`, search and index metadata are now scoped to each document's namespace instead of the whole collection. Without `indexName`, behaviour is unchanged.
|
|
11
|
+
- **Shared `VectorStore` instances in collections:** `DocumentCollection.build()` now raises `ConfigError` when a single `VectorStore` instance would be shared by more than one document, instead of each document silently resetting the previous one's index. Pass a vector store provider config instead.
|
|
12
|
+
- **Web URL re-indexing:** URL sources are now fingerprinted by their fetched content rather than the URL string, so a changed page is re-indexed on the next `build()`.
|
|
13
|
+
- **Query embedder after reload:** Searching an existing index in a new process now embeds queries with the provider and model the index was built with, rather than the constructor default. Configured credentials are reused when the provider matches.
|
|
14
|
+
- **Collection search errors:** `DocumentCollection.search()` (and `ask`/`ask_stream`) now logs per-document search failures instead of silently dropping them, and raises if every document fails.
|
|
15
|
+
- **Web loader User-Agent:** Now reports the actual package version.
|
|
16
|
+
|
|
17
|
+
### Upgrade notes
|
|
18
|
+
- As with every release, cached indexes are rebuilt once on first `build()` because the package version is part of the cache key.
|
|
19
|
+
- Existing Qdrant indexes created with `indexName` are rebuilt once. Their old untagged points stay in the collection but are no longer returned by search; drop and recreate the collection to remove them.
|
|
20
|
+
- Each `build()` on a URL source now fetches the page to check for changes, even when the cached index is reused.
|
|
21
|
+
- When every document in a collection fails to search, the REST server now returns an error response (400 for RAGLite errors, 500 otherwise) instead of empty results.
|
|
22
|
+
|
|
5
23
|
## [1.2.0] - 2026-08-02
|
|
6
24
|
|
|
7
25
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: raglite-toolkit
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.1
|
|
4
4
|
Summary: Build semantic search, multi-provider question answering, and REST APIs over your documents in a few lines of Python.
|
|
5
5
|
Project-URL: Homepage, https://github.com/creatorpiyush/raglite-py
|
|
6
6
|
Project-URL: Repository, https://github.com/creatorpiyush/raglite-py
|
|
@@ -182,6 +182,7 @@ doc = Document("./policy.pdf", {
|
|
|
182
182
|
```
|
|
183
183
|
|
|
184
184
|
### Qdrant Store
|
|
185
|
+
By default each document gets its own collection (`raglite_<namespace>`). Set `indexName` to keep several documents in one shared collection; each document's points are tagged with its namespace, so rebuilding one document never affects the others.
|
|
185
186
|
```python
|
|
186
187
|
doc = Document("./policy.pdf", {
|
|
187
188
|
"vectorStore": {
|
|
@@ -136,6 +136,7 @@ doc = Document("./policy.pdf", {
|
|
|
136
136
|
```
|
|
137
137
|
|
|
138
138
|
### Qdrant Store
|
|
139
|
+
By default each document gets its own collection (`raglite_<namespace>`). Set `indexName` to keep several documents in one shared collection; each document's points are tagged with its namespace, so rebuilding one document never affects the others.
|
|
139
140
|
```python
|
|
140
141
|
doc = Document("./policy.pdf", {
|
|
141
142
|
"vectorStore": {
|
|
@@ -8,7 +8,7 @@ packages = ["src/raglite"]
|
|
|
8
8
|
|
|
9
9
|
[project]
|
|
10
10
|
name = "raglite-toolkit"
|
|
11
|
-
version = "1.2.
|
|
11
|
+
version = "1.2.1"
|
|
12
12
|
description = "Build semantic search, multi-provider question answering, and REST APIs over your documents in a few lines of Python."
|
|
13
13
|
readme = "README.md"
|
|
14
14
|
requires-python = ">=3.10"
|
|
@@ -10,6 +10,7 @@ from .constants import (
|
|
|
10
10
|
DEFAULT_TOP_K,
|
|
11
11
|
)
|
|
12
12
|
from .types import EmbeddingProviderConfig, LLMProviderConfig, VectorStoreProviderConfig
|
|
13
|
+
from .vectordb.base import VectorStore
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
class DocumentOptions(BaseModel):
|
|
@@ -44,6 +45,10 @@ def resolve_config(options: Optional[Union[DocumentOptions, Dict[str, Any]]] = N
|
|
|
44
45
|
if options is None:
|
|
45
46
|
opts = DocumentOptions()
|
|
46
47
|
elif isinstance(options, dict):
|
|
48
|
+
# A VectorStore instance is read straight from the raw options by
|
|
49
|
+
# Document, so keep it out of the provider-config validation.
|
|
50
|
+
if isinstance(options.get("vectorStore"), VectorStore):
|
|
51
|
+
options = {k: v for k, v in options.items() if k != "vectorStore"}
|
|
47
52
|
opts = DocumentOptions.model_validate(options)
|
|
48
53
|
else:
|
|
49
54
|
opts = options
|
|
@@ -5,7 +5,7 @@ try:
|
|
|
5
5
|
PACKAGE_VERSION = importlib.metadata.version("raglite-toolkit")
|
|
6
6
|
except importlib.metadata.PackageNotFoundError:
|
|
7
7
|
PACKAGE_NAME = "raglite-toolkit"
|
|
8
|
-
PACKAGE_VERSION = "1.2.
|
|
8
|
+
PACKAGE_VERSION = "1.2.1" # local development fallback
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
SUPPORTED_EXTENSIONS = {".pdf", ".txt", ".json", ".md", ".markdown", ".docx"}
|
|
@@ -4,11 +4,12 @@ from pathlib import Path
|
|
|
4
4
|
from typing import Any, Dict, Generator, List, Optional, Union
|
|
5
5
|
|
|
6
6
|
from ..config import DocumentOptions, ResolvedConfig, resolve_config
|
|
7
|
-
from ..errors import RagLiteError
|
|
7
|
+
from ..errors import ConfigError, RagLiteError
|
|
8
8
|
from ..llm import generate_answer, stream_answer
|
|
9
9
|
from ..loaders import DirectoryLoader, is_supported_file, is_url
|
|
10
10
|
from ..types import AnswerResult, SearchResult
|
|
11
11
|
from ..utils.logger import Logger, create_logger
|
|
12
|
+
from ..vectordb import VectorStore
|
|
12
13
|
from .document import Document
|
|
13
14
|
|
|
14
15
|
|
|
@@ -91,6 +92,16 @@ class DocumentCollection:
|
|
|
91
92
|
self.logger.warning(msg)
|
|
92
93
|
errors.append({"source": src, "error": msg})
|
|
93
94
|
|
|
95
|
+
# A VectorStore instance has a single namespace, so every document would
|
|
96
|
+
# share it and each build() would reset the previous document's index.
|
|
97
|
+
raw_store = self.options.get("vectorStore") if isinstance(self.options, dict) else None
|
|
98
|
+
if isinstance(raw_store, VectorStore) and len(set(file_list)) > 1:
|
|
99
|
+
raise ConfigError(
|
|
100
|
+
"A VectorStore instance cannot be shared by multiple documents in a "
|
|
101
|
+
"DocumentCollection. Pass a vector store provider config "
|
|
102
|
+
'(e.g. {"provider": "qdrant", ...}) instead.'
|
|
103
|
+
)
|
|
104
|
+
|
|
94
105
|
total_chunks = 0
|
|
95
106
|
cached_docs = 0
|
|
96
107
|
new_docs = 0
|
|
@@ -146,12 +157,19 @@ class DocumentCollection:
|
|
|
146
157
|
st = score_threshold if score_threshold is not None else opts.get("scoreThreshold", opts.get("score_threshold", self.config.scoreThreshold))
|
|
147
158
|
|
|
148
159
|
all_hits: List[SearchResult] = []
|
|
160
|
+
failures: List[Exception] = []
|
|
149
161
|
for doc in self.documents.values():
|
|
150
162
|
try:
|
|
151
163
|
hits = doc.search(query, top_k=tk * 2, score_threshold=st)
|
|
152
164
|
all_hits.extend(hits)
|
|
153
|
-
except Exception:
|
|
154
|
-
|
|
165
|
+
except Exception as err:
|
|
166
|
+
failures.append(err)
|
|
167
|
+
self.logger.warning(f'Search failed for document "{doc.file_path}": {err}')
|
|
168
|
+
|
|
169
|
+
# One broken document should not hide results from the others, but if
|
|
170
|
+
# every document failed the caller needs the error, not an empty list.
|
|
171
|
+
if len(failures) == len(self.documents):
|
|
172
|
+
raise failures[0]
|
|
155
173
|
|
|
156
174
|
all_hits.sort(key=lambda h: h.score, reverse=True)
|
|
157
175
|
return all_hits[:tk]
|
|
@@ -10,7 +10,13 @@ from ..errors import FileNotIndexedError, LoaderError, RagLiteError
|
|
|
10
10
|
from ..llm import generate_answer, stream_answer
|
|
11
11
|
from ..loaders import get_loader, is_url
|
|
12
12
|
from ..retrieval import Retriever
|
|
13
|
-
from ..types import
|
|
13
|
+
from ..types import (
|
|
14
|
+
AnswerResult,
|
|
15
|
+
ChunkMetadata,
|
|
16
|
+
EmbeddingProviderConfig,
|
|
17
|
+
IndexMetadata,
|
|
18
|
+
StoredChunk,
|
|
19
|
+
)
|
|
14
20
|
from ..utils.hash import hash_file, hash_string, namespace_from_path
|
|
15
21
|
from ..utils.logger import create_logger
|
|
16
22
|
from ..vectordb import MemoryVectorStore, VectorStore, create_vector_store
|
|
@@ -74,16 +80,17 @@ class Document:
|
|
|
74
80
|
if should_rebuild is None:
|
|
75
81
|
should_rebuild = opts.get("rebuild", False)
|
|
76
82
|
|
|
77
|
-
if not os.path.exists(self.file_path):
|
|
83
|
+
if not is_url(self.file_path) and not os.path.exists(self.file_path):
|
|
78
84
|
raise LoaderError(f"File does not exist: {self.file_path}")
|
|
79
85
|
|
|
80
86
|
self.store.load()
|
|
81
87
|
existing = self.store.read_index_metadata()
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
88
|
+
# Web pages change without their URL changing, so fingerprint the
|
|
89
|
+
# fetched content rather than the URL.
|
|
90
|
+
text: Optional[str] = None
|
|
91
|
+
if is_url(self.file_path):
|
|
92
|
+
text = get_loader(self.file_path).load()
|
|
93
|
+
source_hash = hash_string(text) if text is not None else hash_file(self.file_path)
|
|
87
94
|
|
|
88
95
|
if (
|
|
89
96
|
not should_rebuild
|
|
@@ -110,8 +117,8 @@ class Document:
|
|
|
110
117
|
self.store.reset()
|
|
111
118
|
self.store.load()
|
|
112
119
|
|
|
113
|
-
|
|
114
|
-
|
|
120
|
+
if text is None:
|
|
121
|
+
text = get_loader(self.file_path).load()
|
|
115
122
|
if not text:
|
|
116
123
|
raise LoaderError(
|
|
117
124
|
f"Loader returned empty text for {self.file_path}"
|
|
@@ -340,19 +347,10 @@ class Document:
|
|
|
340
347
|
f'No RagLite index found for "{self.file_path}". Call build() first.'
|
|
341
348
|
)
|
|
342
349
|
|
|
343
|
-
embed_config = self.config.embeddings
|
|
344
|
-
if embed_config.model is None:
|
|
345
|
-
from ..types import EmbeddingProviderConfig
|
|
346
|
-
|
|
347
|
-
embed_config = EmbeddingProviderConfig(
|
|
348
|
-
provider=embed_config.provider,
|
|
349
|
-
model=existing.embeddingModel,
|
|
350
|
-
apiKey=embed_config.apiKey,
|
|
351
|
-
baseURL=embed_config.baseURL,
|
|
352
|
-
)
|
|
353
|
-
|
|
354
350
|
if self.embedder is None:
|
|
355
|
-
self.embedder = create_embedder(
|
|
351
|
+
self.embedder = create_embedder(
|
|
352
|
+
_query_embeddings_config(existing, self.config.embeddings)
|
|
353
|
+
)
|
|
356
354
|
self.ready = True
|
|
357
355
|
|
|
358
356
|
def _cache_still_valid(
|
|
@@ -378,3 +376,19 @@ class Document:
|
|
|
378
376
|
if req_model is not None and req_model != existing.embeddingModel:
|
|
379
377
|
return False
|
|
380
378
|
return True
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _query_embeddings_config(
|
|
382
|
+
existing: IndexMetadata, configured: EmbeddingProviderConfig
|
|
383
|
+
) -> EmbeddingProviderConfig:
|
|
384
|
+
"""Queries must be embedded with the same provider and model as the index,
|
|
385
|
+
which may differ from the constructor default when ``build(embeddings=...)``
|
|
386
|
+
overrode it. Credentials from the configured provider are reused when it
|
|
387
|
+
matches; otherwise the provider falls back to its environment variables."""
|
|
388
|
+
same_provider = configured.provider == existing.embeddingProvider
|
|
389
|
+
return EmbeddingProviderConfig(
|
|
390
|
+
provider=existing.embeddingProvider,
|
|
391
|
+
model=existing.embeddingModel,
|
|
392
|
+
apiKey=configured.apiKey if same_provider else None,
|
|
393
|
+
baseURL=configured.baseURL if same_provider else None,
|
|
394
|
+
)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import html
|
|
2
2
|
import re
|
|
3
3
|
|
|
4
|
+
from ..constants import PACKAGE_VERSION
|
|
4
5
|
from ..errors import LoaderError
|
|
5
6
|
from .base import BaseLoader
|
|
6
7
|
|
|
@@ -18,7 +19,7 @@ class WebLoader(BaseLoader):
|
|
|
18
19
|
import httpx
|
|
19
20
|
|
|
20
21
|
headers = {
|
|
21
|
-
"User-Agent": "RAGLite/
|
|
22
|
+
"User-Agent": f"RAGLite/{PACKAGE_VERSION} (Python/3.10+)",
|
|
22
23
|
"Accept": "text/html,text/plain,application/xhtml+xml;q=0.9,*/*;q=0.8",
|
|
23
24
|
}
|
|
24
25
|
response = httpx.get(self.url, headers=headers, follow_redirects=True, timeout=15.0)
|
|
@@ -31,12 +31,22 @@ class QdrantVectorStore(VectorStore):
|
|
|
31
31
|
self._url = url.rstrip("/")
|
|
32
32
|
self._api_key = api_key
|
|
33
33
|
self._collection = collection_name or f"raglite_{namespace}"
|
|
34
|
+
# With an explicit collection name, several namespaces may share one
|
|
35
|
+
# collection, so every operation is scoped to this namespace instead
|
|
36
|
+
# of touching the whole collection.
|
|
37
|
+
self._shared = collection_name is not None
|
|
38
|
+
self._metadata_id = (
|
|
39
|
+
_uuid_from_str(f"{namespace}__metadata__") if self._shared else _METADATA_UUID
|
|
40
|
+
)
|
|
34
41
|
self._count_cache: int = 0
|
|
35
42
|
|
|
36
43
|
@property
|
|
37
44
|
def namespace(self) -> str:
|
|
38
45
|
return self._namespace
|
|
39
46
|
|
|
47
|
+
def _namespace_filter(self) -> dict:
|
|
48
|
+
return {"must": [{"key": "namespace", "match": {"value": self._namespace}}]}
|
|
49
|
+
|
|
40
50
|
def _headers(self) -> dict:
|
|
41
51
|
h: dict = {"Content-Type": "application/json"}
|
|
42
52
|
if self._api_key:
|
|
@@ -70,7 +80,14 @@ class QdrantVectorStore(VectorStore):
|
|
|
70
80
|
|
|
71
81
|
def reset(self) -> None:
|
|
72
82
|
try:
|
|
73
|
-
|
|
83
|
+
if self._shared:
|
|
84
|
+
self._request(
|
|
85
|
+
"POST",
|
|
86
|
+
f"/collections/{self._collection}/points/delete?wait=true",
|
|
87
|
+
{"filter": self._namespace_filter()},
|
|
88
|
+
)
|
|
89
|
+
else:
|
|
90
|
+
self._request("DELETE", f"/collections/{self._collection}")
|
|
74
91
|
except VectorDBError:
|
|
75
92
|
pass
|
|
76
93
|
self._count_cache = 0
|
|
@@ -85,6 +102,7 @@ class QdrantVectorStore(VectorStore):
|
|
|
85
102
|
"vector": c.embedding,
|
|
86
103
|
"payload": {
|
|
87
104
|
"id": c.id,
|
|
105
|
+
"namespace": self._namespace,
|
|
88
106
|
"text": c.text,
|
|
89
107
|
"metadata": c.metadata.model_dump(by_alias=True),
|
|
90
108
|
"isMetadata": False,
|
|
@@ -96,15 +114,18 @@ class QdrantVectorStore(VectorStore):
|
|
|
96
114
|
self._count_cache += len(chunks)
|
|
97
115
|
|
|
98
116
|
def search(self, embedding: List[float], top_k: int) -> List[VectorSearchHit]:
|
|
117
|
+
body: dict = {
|
|
118
|
+
"vector": embedding,
|
|
119
|
+
"limit": top_k + 1,
|
|
120
|
+
"with_payload": True,
|
|
121
|
+
}
|
|
122
|
+
if self._shared:
|
|
123
|
+
body["filter"] = self._namespace_filter()
|
|
99
124
|
try:
|
|
100
125
|
data = self._request(
|
|
101
126
|
"POST",
|
|
102
127
|
f"/collections/{self._collection}/points/search",
|
|
103
|
-
|
|
104
|
-
"vector": embedding,
|
|
105
|
-
"limit": top_k + 1,
|
|
106
|
-
"with_payload": True,
|
|
107
|
-
},
|
|
128
|
+
body,
|
|
108
129
|
)
|
|
109
130
|
except VectorDBError:
|
|
110
131
|
return []
|
|
@@ -112,7 +133,7 @@ class QdrantVectorStore(VectorStore):
|
|
|
112
133
|
hits: List[VectorSearchHit] = []
|
|
113
134
|
for r in data.get("result", []):
|
|
114
135
|
payload = r.get("payload", {}) or {}
|
|
115
|
-
if r.get("id") ==
|
|
136
|
+
if r.get("id") == self._metadata_id or payload.get("isMetadata"):
|
|
116
137
|
continue
|
|
117
138
|
raw_meta = payload.get("metadata") or {}
|
|
118
139
|
metadata = ChunkMetadata(
|
|
@@ -141,13 +162,14 @@ class QdrantVectorStore(VectorStore):
|
|
|
141
162
|
zero_vec = [0.0] * dim
|
|
142
163
|
payload_data = metadata.model_dump(by_alias=True)
|
|
143
164
|
payload_data["isMetadata"] = True
|
|
165
|
+
payload_data["namespace"] = self._namespace
|
|
144
166
|
self._request(
|
|
145
167
|
"PUT",
|
|
146
168
|
f"/collections/{self._collection}/points",
|
|
147
169
|
{
|
|
148
170
|
"points": [
|
|
149
171
|
{
|
|
150
|
-
"id":
|
|
172
|
+
"id": self._metadata_id,
|
|
151
173
|
"vector": zero_vec,
|
|
152
174
|
"payload": payload_data,
|
|
153
175
|
}
|
|
@@ -161,7 +183,7 @@ class QdrantVectorStore(VectorStore):
|
|
|
161
183
|
data = self._request(
|
|
162
184
|
"POST",
|
|
163
185
|
f"/collections/{self._collection}/points",
|
|
164
|
-
{"ids": [
|
|
186
|
+
{"ids": [self._metadata_id], "with_payload": True},
|
|
165
187
|
)
|
|
166
188
|
except VectorDBError:
|
|
167
189
|
return None
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Regression tests for URL indexing and shared vector stores across documents.
|
|
3
|
+
"""
|
|
4
|
+
import shutil
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from unittest.mock import patch
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from raglite.core.collection import DocumentCollection
|
|
12
|
+
from raglite.core.document import Document
|
|
13
|
+
from raglite.embeddings.local import LocalEmbedder
|
|
14
|
+
from raglite.errors import ConfigError
|
|
15
|
+
from raglite.types import ChunkMetadata, EmbeddingProviderConfig, StoredChunk
|
|
16
|
+
from raglite.vectordb import MemoryVectorStore
|
|
17
|
+
from raglite.vectordb.qdrant import QdrantVectorStore
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def unit_vec_384():
|
|
21
|
+
return [1.0] + [0.0] * 383
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.fixture
|
|
25
|
+
def tmp_dir():
|
|
26
|
+
d = tempfile.mkdtemp(prefix="raglite_regress_")
|
|
27
|
+
yield d
|
|
28
|
+
shutil.rmtree(d, ignore_errors=True)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_document_builds_from_url(tmp_dir):
|
|
32
|
+
with patch(
|
|
33
|
+
"raglite.loaders.web.WebLoader.load", return_value="Refunds are issued within 30 days."
|
|
34
|
+
), patch.object(
|
|
35
|
+
LocalEmbedder,
|
|
36
|
+
"embed_documents",
|
|
37
|
+
side_effect=lambda texts: [unit_vec_384() for _ in texts],
|
|
38
|
+
):
|
|
39
|
+
doc = Document("https://example.com/policy", {"storeDir": tmp_dir, "logLevel": "silent"})
|
|
40
|
+
result = doc.build()
|
|
41
|
+
|
|
42
|
+
assert result["chunkCount"] == 1
|
|
43
|
+
assert result["cached"] is False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_collection_rejects_shared_store_instance(tmp_dir):
|
|
47
|
+
(Path(tmp_dir) / "a.txt").write_text("first document", encoding="utf-8")
|
|
48
|
+
(Path(tmp_dir) / "b.txt").write_text("second document", encoding="utf-8")
|
|
49
|
+
store = MemoryVectorStore(str(Path(tmp_dir) / ".raglite"), "shared")
|
|
50
|
+
|
|
51
|
+
collection = DocumentCollection(tmp_dir, {"vectorStore": store, "logLevel": "silent"})
|
|
52
|
+
with pytest.raises(ConfigError):
|
|
53
|
+
collection.build()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_qdrant_shared_collection_is_scoped_to_namespace():
|
|
57
|
+
calls = []
|
|
58
|
+
|
|
59
|
+
def fake_request(self, method, path, body=None):
|
|
60
|
+
calls.append((self.namespace, method, path, body))
|
|
61
|
+
return {"result": []}
|
|
62
|
+
|
|
63
|
+
with patch.object(QdrantVectorStore, "_request", fake_request):
|
|
64
|
+
a = QdrantVectorStore("http://qdrant:6333", "ns-a", collection_name="shared")
|
|
65
|
+
b = QdrantVectorStore("http://qdrant:6333", "ns-b", collection_name="shared")
|
|
66
|
+
|
|
67
|
+
a.reset()
|
|
68
|
+
a.add(
|
|
69
|
+
[
|
|
70
|
+
StoredChunk(
|
|
71
|
+
id="ns-a_000001",
|
|
72
|
+
text="hello",
|
|
73
|
+
embedding=[1.0, 0.0, 0.0],
|
|
74
|
+
metadata=ChunkMetadata(source="a.txt", chunk=1, totalChunks=1),
|
|
75
|
+
)
|
|
76
|
+
]
|
|
77
|
+
)
|
|
78
|
+
a.search([1.0, 0.0, 0.0], 3)
|
|
79
|
+
a.read_index_metadata()
|
|
80
|
+
b.read_index_metadata()
|
|
81
|
+
|
|
82
|
+
assert not any(method == "DELETE" for _, method, _, _ in calls)
|
|
83
|
+
|
|
84
|
+
delete = next(c for c in calls if c[2].endswith("/points/delete?wait=true"))
|
|
85
|
+
assert delete[3]["filter"]["must"][0]["match"]["value"] == "ns-a"
|
|
86
|
+
|
|
87
|
+
upsert = next(c for c in calls if c[1] == "PUT" and c[2].endswith("/points"))
|
|
88
|
+
assert upsert[3]["points"][0]["payload"]["namespace"] == "ns-a"
|
|
89
|
+
|
|
90
|
+
search = next(c for c in calls if c[2].endswith("/points/search"))
|
|
91
|
+
assert search[3]["filter"]["must"][0]["match"]["value"] == "ns-a"
|
|
92
|
+
|
|
93
|
+
meta_ids = [c[3]["ids"][0] for c in calls if c[1] == "POST" and c[2] == "/collections/shared/points"]
|
|
94
|
+
assert len(meta_ids) == 2
|
|
95
|
+
assert meta_ids[0] != meta_ids[1]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_qdrant_dedicated_collection_keeps_existing_behaviour():
|
|
99
|
+
calls = []
|
|
100
|
+
|
|
101
|
+
def fake_request(self, method, path, body=None):
|
|
102
|
+
calls.append((method, path, body))
|
|
103
|
+
return {"result": []}
|
|
104
|
+
|
|
105
|
+
with patch.object(QdrantVectorStore, "_request", fake_request):
|
|
106
|
+
store = QdrantVectorStore("http://qdrant:6333", "ns-a")
|
|
107
|
+
store.reset()
|
|
108
|
+
store.search([1.0, 0.0, 0.0], 3)
|
|
109
|
+
|
|
110
|
+
assert ("DELETE", "/collections/raglite_ns-a", None) in calls
|
|
111
|
+
search = next(c for c in calls if c[1].endswith("/points/search"))
|
|
112
|
+
assert "filter" not in search[2]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class FakeEmbedder:
|
|
116
|
+
def __init__(self, config):
|
|
117
|
+
self.provider = config.provider
|
|
118
|
+
self.model = config.model or "fake-model"
|
|
119
|
+
self.dimensions = 384
|
|
120
|
+
|
|
121
|
+
def embed_documents(self, texts):
|
|
122
|
+
return [unit_vec_384() for _ in texts]
|
|
123
|
+
|
|
124
|
+
def embed_query(self, text):
|
|
125
|
+
return unit_vec_384()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@pytest.fixture
|
|
129
|
+
def embedder_configs():
|
|
130
|
+
configs = []
|
|
131
|
+
|
|
132
|
+
def fake_create(config):
|
|
133
|
+
configs.append(config)
|
|
134
|
+
return FakeEmbedder(config)
|
|
135
|
+
|
|
136
|
+
with patch("raglite.core.document.create_embedder", side_effect=fake_create):
|
|
137
|
+
yield configs
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_url_reindexes_when_page_content_changes(tmp_dir, embedder_configs):
|
|
141
|
+
page = {"text": "Refunds are issued within 30 days."}
|
|
142
|
+
opts = {"storeDir": tmp_dir, "logLevel": "silent"}
|
|
143
|
+
url = "https://example.com/policy"
|
|
144
|
+
|
|
145
|
+
with patch("raglite.loaders.web.WebLoader.load", side_effect=lambda: page["text"]):
|
|
146
|
+
assert Document(url, opts).build()["cached"] is False
|
|
147
|
+
assert Document(url, opts).build()["cached"] is True
|
|
148
|
+
page["text"] = "Refunds are issued within 60 days."
|
|
149
|
+
assert Document(url, opts).build()["cached"] is False
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_query_embedder_uses_index_provider_after_reload(tmp_dir, embedder_configs):
|
|
153
|
+
path = Path(tmp_dir) / "policy.txt"
|
|
154
|
+
path.write_text("Refunds are issued within 30 days.", encoding="utf-8")
|
|
155
|
+
opts = {"storeDir": tmp_dir, "logLevel": "silent"}
|
|
156
|
+
|
|
157
|
+
Document(str(path), opts).build(
|
|
158
|
+
embeddings=EmbeddingProviderConfig(provider="openai", model="text-embedding-3-small")
|
|
159
|
+
)
|
|
160
|
+
embedder_configs.clear()
|
|
161
|
+
Document(str(path), opts).search("refund")
|
|
162
|
+
|
|
163
|
+
assert len(embedder_configs) == 1
|
|
164
|
+
assert embedder_configs[0].provider == "openai"
|
|
165
|
+
assert embedder_configs[0].model == "text-embedding-3-small"
|
|
166
|
+
assert embedder_configs[0].apiKey is None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_query_embedder_reuses_credentials_for_same_provider(tmp_dir, embedder_configs):
|
|
170
|
+
path = Path(tmp_dir) / "policy.txt"
|
|
171
|
+
path.write_text("Refunds are issued within 30 days.", encoding="utf-8")
|
|
172
|
+
opts = {
|
|
173
|
+
"storeDir": tmp_dir,
|
|
174
|
+
"logLevel": "silent",
|
|
175
|
+
"embeddings": {"provider": "openai", "apiKey": "sk-test"},
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
Document(str(path), opts).build()
|
|
179
|
+
embedder_configs.clear()
|
|
180
|
+
Document(str(path), opts).search("refund")
|
|
181
|
+
|
|
182
|
+
assert embedder_configs[0].apiKey == "sk-test"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _two_doc_collection(tmp_dir):
|
|
186
|
+
(Path(tmp_dir) / "policy.txt").write_text("Refunds within 30 days.", encoding="utf-8")
|
|
187
|
+
(Path(tmp_dir) / "shipping.txt").write_text("Delivery takes 2 days.", encoding="utf-8")
|
|
188
|
+
collection = DocumentCollection(
|
|
189
|
+
tmp_dir, {"storeDir": str(Path(tmp_dir) / ".raglite"), "logLevel": "silent"}
|
|
190
|
+
)
|
|
191
|
+
collection.build()
|
|
192
|
+
return collection
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def test_collection_search_survives_one_failing_document(tmp_dir, embedder_configs):
|
|
196
|
+
collection = _two_doc_collection(tmp_dir)
|
|
197
|
+
broken = next(iter(collection.documents.values()))
|
|
198
|
+
with patch.object(broken, "search", side_effect=RuntimeError("boom")):
|
|
199
|
+
hits = collection.search("refund", top_k=5)
|
|
200
|
+
assert len(hits) == 1
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_collection_search_raises_when_every_document_fails(tmp_dir, embedder_configs):
|
|
204
|
+
collection = _two_doc_collection(tmp_dir)
|
|
205
|
+
docs = list(collection.documents.values())
|
|
206
|
+
with patch.object(docs[0], "search", side_effect=RuntimeError("bad api key")), patch.object(
|
|
207
|
+
docs[1], "search", side_effect=RuntimeError("bad api key")
|
|
208
|
+
):
|
|
209
|
+
with pytest.raises(RuntimeError, match="bad api key"):
|
|
210
|
+
collection.search("refund")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|